import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
initStderrTee("admin");
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { execFileSync } from "node:child_process";
import { cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { writeKey, validateKey, keyFilePath } from "../../../../lib/anthropic-key/dist/index.js";
import { createHash, randomInt } from "node:crypto";
import { createConnection } from "node:net";
import { homedir, hostname as osHostname } from "node:os";
import QRCode from "qrcode";
import { getSession, closeDriver } from "./lib/neo4j.js";
import { getOnboardingState, completeOnboardingStep } from "./lib/onboarding.js";
import { registerReviewTools } from "./lib/review-tools.js";
const server = new McpServer({
    name: "admin",
    version: "0.1.0",
});
const PLATFORM_ROOT = process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../..");
const ACCOUNT_ID = process.env.ACCOUNT_ID;
if (!ACCOUNT_ID) {
    throw new Error("ACCOUNT_ID environment variable is required");
}
const PLATFORM_PORT = process.env.PLATFORM_PORT;
if (!PLATFORM_PORT) {
    throw new Error("PLATFORM_PORT environment variable is required — set by getMcpServers() in claude-agent.ts");
}
// Brand-aware config directory — reads configDir from brand.json stamped at install time.
// No fallback: if brand.json is missing or incomplete, the platform wasn't properly installed.
function resolveConfigDir() {
    const brandPath = resolve(PLATFORM_ROOT, "config", "brand.json");
    if (!existsSync(brandPath)) {
        throw new Error(`brand.json not found at ${brandPath} — platform not properly installed`);
    }
    try {
        const brand = JSON.parse(readFileSync(brandPath, "utf-8"));
        if (!brand.configDir) {
            throw new Error(`brand.json at ${brandPath} is missing the configDir field`);
        }
        return brand.configDir;
    }
    catch (err) {
        if (err instanceof SyntaxError) {
            throw new Error(`brand.json at ${brandPath} is not valid JSON: ${err.message}`);
        }
        throw err;
    }
}
const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
const REMOTE_PASSWORD_FILE = resolve(CONFIG_DIR, ".remote-password");
// Resolve account directory
function getAccountDir() {
    const dir = resolve(PLATFORM_ROOT, "..", "data/accounts", ACCOUNT_ID);
    if (!existsSync(dir)) {
        throw new Error(`Account directory not found: ${dir}`);
    }
    return dir;
}
async function readAccountConfig() {
    const configPath = join(getAccountDir(), "account.json");
    const content = await readFile(configPath, "utf-8");
    return JSON.parse(content);
}
const STAGING_ROOT = resolve(PLATFORM_ROOT, "../premium-plugins");
const PLUGINS_DIR = resolve(PLATFORM_ROOT, "plugins");
const CORE_PLUGINS = ["admin", "memory", "docs", "cloudflare", "anthropic"];
const USERS_FILE = resolve(PLATFORM_ROOT, "config", "users.json");
function hashPin(pin) {
    return createHash("sha256").update(pin).digest("hex");
}
function readUsersJson() {
    if (!existsSync(USERS_FILE)) {
        throw new Error("users.json not found — run the seed script first");
    }
    const raw = readFileSync(USERS_FILE, "utf-8").trim();
    if (!raw)
        return [];
    return JSON.parse(raw);
}
function writeUsersJson(users) {
    writeFileSync(USERS_FILE, JSON.stringify(users, null, 2) + "\n", "utf-8");
}
function generateUniquePin(users, maxRetries = 10) {
    const existingPins = new Set(users.map(u => u.pin));
    for (let i = 0; i < maxRetries; i++) {
        const raw = String(randomInt(0, 10000)).padStart(4, "0");
        const hashed = hashPin(raw);
        if (!existingPins.has(hashed)) {
            return raw;
        }
        console.error(`[admin] PIN collision during admin-add: rejecting duplicate (attempt ${i + 1}/${maxRetries})`);
    }
    throw new Error("Could not generate a unique PIN after multiple attempts. Specify a PIN manually.");
}
const VALID_PLUGIN_NAME = /^[a-z0-9-]+$/;
/** Parse YAML frontmatter from markdown content. Returns key-value pairs. */
function parseFrontmatter(content) {
    const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
    if (!match)
        return {};
    const result = {};
    let currentKey = "";
    let currentArray = null;
    for (const line of match[1].split("\n")) {
        const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
        if (kvMatch) {
            if (currentArray && currentKey) {
                result[currentKey] = currentArray;
                currentArray = null;
            }
            const [, key, rawValue] = kvMatch;
            const value = rawValue.replace(/^["']|["']$/g, "").trim();
            if (value) {
                result[key] = value;
                currentKey = key;
            }
            else {
                // Key with no inline value — next lines may be array items
                currentKey = key;
                currentArray = [];
            }
        }
        else if (currentArray !== null) {
            const itemMatch = line.match(/^\s*-\s+(.+)$/);
            if (itemMatch) {
                currentArray.push(itemMatch[1].trim());
            }
        }
    }
    if (currentArray && currentKey) {
        result[currentKey] = currentArray;
    }
    return result;
}
// ===================================================================
// System tools
// ===================================================================
function checkPort(port, timeoutMs = 1000) {
    return new Promise((res) => {
        const socket = createConnection(port, "127.0.0.1");
        socket.setTimeout(timeoutMs);
        socket.once("connect", () => { socket.destroy(); res(true); });
        socket.once("error", () => { socket.destroy(); res(false); });
        socket.once("timeout", () => { socket.destroy(); res(false); });
    });
}
server.tool("system-status", "Check health of all Maxy platform services: Neo4j, Ollama, Cloudflare tunnel, crontab (Maxy cron entries), VNC, Chrome (CDP), specialist agents, and deployed brand identity.", {}, async () => {
    const checks = {};
    try {
        const neo4jUri = process.env.NEO4J_URI ?? "bolt://localhost:7687";
        const res = await fetch(neo4jUri.replace("bolt://", "http://").replace("7687", "7474"), { signal: AbortSignal.timeout(3000) });
        checks.neo4j = res.ok ? "healthy" : `unhealthy (${res.status})`;
    }
    catch (err) {
        checks.neo4j = `unreachable: ${err instanceof Error ? err.message : String(err)}`;
    }
    try {
        const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
        const res = await fetch(`${ollamaUrl}/api/tags`, {
            signal: AbortSignal.timeout(3000),
        });
        if (res.ok) {
            const data = (await res.json());
            const models = data.models?.map((m) => m.name).join(", ") ?? "none";
            checks.ollama = `healthy (models: ${models})`;
        }
        else {
            checks.ollama = `unhealthy (${res.status})`;
        }
    }
    catch (err) {
        checks.ollama = `unreachable: ${err instanceof Error ? err.message : String(err)}`;
    }
    // Cloudflare tunnel: lightweight process check (full status via tunnel-status in cloudflare plugin)
    try {
        execFileSync("pgrep", ["-x", "cloudflared"], { timeout: 2000 });
        checks.cloudflare = "running";
    }
    catch {
        // pgrep exits non-zero if no process found
        try {
            execFileSync("which", ["cloudflared"], { encoding: "utf-8", timeout: 3000 });
            checks.cloudflare = "installed but not running";
        }
        catch {
            checks.cloudflare = "not installed";
        }
    }
    // Crontab — Maxy cron entries registered by the installer
    try {
        const crontabOutput = execFileSync("crontab", ["-l"], {
            encoding: "utf-8",
            timeout: 3000,
        });
        const beginIdx = crontabOutput.indexOf("# BEGIN MAXY CRONS");
        const endIdx = crontabOutput.indexOf("# END MAXY CRONS");
        if (beginIdx === -1 || endIdx === -1) {
            checks.crontab = "no Maxy crons registered — run the installer to register them";
        }
        else {
            const block = crontabOutput.slice(beginIdx, endIdx);
            const entries = block
                .split("\n")
                .filter((line) => line.trim() && !line.startsWith("#"));
            if (entries.length === 0) {
                checks.crontab = "Maxy cron block present but empty";
            }
            else {
                const names = entries.map((line) => {
                    const commentMatch = line.match(/#\s*(.+)$/);
                    return commentMatch ? commentMatch[1].trim() : line.trim().split(/\s+/).slice(-1)[0];
                });
                checks.crontab = `${entries.length} registered: ${names.join(", ")}`;
            }
        }
    }
    catch {
        checks.crontab = "no crontab configured for this user";
    }
    // VNC display server
    checks.vnc = (await checkPort(5900)) ? "running" : "not running";
    // Chrome CDP — browser-specialist depends on this
    const chromeUp = await checkPort(9222);
    checks.chrome = chromeUp
        ? "running"
        : "not running — browser-specialist degraded (CDP port 9222 not listening)";
    // Specialist agents — read AGENTS.md registry, verify files exist
    try {
        const accountDir = getAccountDir();
        const agentsMdPath = join(accountDir, "agents/admin/AGENTS.md");
        const specialistsAgentsDir = join(accountDir, "specialists/agents");
        if (!existsSync(agentsMdPath)) {
            checks.specialists = "not configured (AGENTS.md missing)";
        }
        else {
            const content = readFileSync(agentsMdPath, "utf-8");
            const entries = content.match(/^- \*\*specialists:([^*]+)\*\*/gm);
            if (!entries || entries.length === 0) {
                checks.specialists = "not configured (AGENTS.md empty)";
            }
            else {
                const lines = [];
                for (const entry of entries) {
                    const nameMatch = entry.match(/specialists:([^*]+)/);
                    if (!nameMatch)
                        continue;
                    const name = nameMatch[1];
                    const fileExists = existsSync(join(specialistsAgentsDir, `${name}.md`));
                    let status = fileExists ? "ok" : "missing file";
                    if (name === "browser-specialist" && !chromeUp) {
                        status = "degraded — chrome not running";
                    }
                    lines.push(`  ${name}: ${status}`);
                }
                checks.specialists = `${entries.length} registered\n${lines.join("\n")}`;
            }
        }
    }
    catch {
        checks.specialists = "error reading specialist registry";
    }
    // OS hostname — the device's actual network name, used for URL construction
    try {
        checks.hostname = osHostname();
    }
    catch (err) {
        console.error("[system-status] os.hostname() failed, falling back to brand.hostname", err);
        checks.hostname = "unknown (os.hostname failed)";
    }
    // Running port — PLATFORM_PORT is what the parent process passes to MCP subprocesses;
    // PORT is the main-process variable (available in dev); 19200 is the install default.
    checks.port = process.env.PLATFORM_PORT ?? process.env.PORT ?? "19200";
    console.error(`[system-status] port=${checks.port} hostname=${checks.hostname}`);
    // Local URL — pre-computed for agent URL construction (avoids hostname/port assembly errors)
    checks.localUrl = `http://${checks.hostname}.local:${checks.port}`;
    // Brand identity — read build-time manifest stamped by the bundler
    try {
        const brandPath = resolve(PLATFORM_ROOT, "config/brand.json");
        if (!existsSync(brandPath)) {
            checks.brand = "not configured (dev environment)";
        }
        else {
            const brand = JSON.parse(readFileSync(brandPath, "utf-8"));
            checks.brand = `${brand.productName ?? "unknown"} (hostname: ${brand.hostname ?? "unknown"})`;
        }
    }
    catch (err) {
        checks.brand = `error reading brand config: ${err instanceof Error ? err.message : String(err)}`;
    }
    const formatted = Object.entries(checks)
        .map(([service, status]) => `${service}: ${status}`)
        .join("\n");
    return { content: [{ type: "text", text: formatted }] };
});
server.tool("brand-settings", "Read the brand/styling configuration for the current account.", {}, async () => {
    try {
        const config = await readAccountConfig();
        const text = config.brand
            ? JSON.stringify(config.brand, null, 2)
            : "No brand settings configured for this account.";
        return {
            content: [{ type: "text", text }],
        };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("account-manage", "Read the account configuration (tier, domains, settings).", {}, async () => {
    try {
        const config = await readAccountConfig();
        return {
            content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
        };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("account-update", "Update a user-configurable setting in account.json. Valid fields: outputStyle (default|explanatory), thinkingView (default|expanded|collapsed), effort (low|medium|high|max|auto), contextMode (managed|claude-code), adminModel (any Anthropic model ID), publicModel (any Anthropic model ID), defaultAgent (slug of an existing public agent, or empty string to clear). Changes take effect on the next session.", {
    field: z.enum(["outputStyle", "thinkingView", "effort", "contextMode", "adminModel", "publicModel", "defaultAgent"]),
    value: z.string(),
}, async ({ field, value }) => {
    const VALID = {
        outputStyle: ["default", "explanatory"],
        thinkingView: ["default", "expanded", "collapsed"],
        effort: ["low", "medium", "high", "max", "auto"],
        contextMode: ["managed", "claude-code"],
    };
    // defaultAgent: non-empty slug must reference an existing agent; empty string clears the field
    if (field === "defaultAgent") {
        const slug = value.trim();
        if (slug) {
            const agentConfigPath = join(getAccountDir(), "agents", slug, "config.json");
            if (!existsSync(agentConfigPath)) {
                return {
                    content: [{ type: "text", text: `Agent '${slug}' not found — no config.json in agents/${slug}/` }],
                    isError: true,
                };
            }
        }
        // empty slug is valid — handled below as a delete operation
    }
    // Model fields accept any non-empty string (model IDs change frequently)
    const FREE_FORM_FIELDS = ["adminModel", "publicModel"];
    if (FREE_FORM_FIELDS.includes(field)) {
        if (!value.trim()) {
            return {
                content: [{ type: "text", text: `Invalid value for ${field}. Must be a non-empty model ID.` }],
                isError: true,
            };
        }
    }
    else if (field !== "defaultAgent" && !VALID[field].includes(value)) {
        console.error(`[admin:account-update] rejected field=${field} value="${value}" valid=[${VALID[field].join(", ")}]`);
        return {
            content: [{ type: "text", text: `Invalid value "${value}" for ${field}. Valid values: ${VALID[field].join(", ")}` }],
            isError: true,
        };
    }
    try {
        const configPath = join(getAccountDir(), "account.json");
        const config = await readAccountConfig();
        const oldDefault = field === "defaultAgent" ? config.defaultAgent ?? "(none)" : undefined;
        if (field === "defaultAgent" && !value.trim()) {
            delete config.defaultAgent;
        }
        else {
            config[field] = value;
        }
        await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
        console.error(`[admin:account-update] wrote field=${field} value="${value}" path=${configPath}`);
        if (field === "defaultAgent") {
            if (!value.trim()) {
                return {
                    content: [{ type: "text", text: `Default agent cleared (was "${oldDefault}"). Visitors to the root URL will see the first available agent, or a "no agents" message if none exist. Takes effect on next page load.` }],
                };
            }
            return {
                content: [{ type: "text", text: `Default agent changed from "${oldDefault}" to "${value}". Takes effect on next page load.` }],
            };
        }
        return {
            content: [{ type: "text", text: `Updated ${field} to "${value}". Takes effect on next session.` }],
        };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
// ===================================================================
// Admin user management tools
// ===================================================================
server.tool("admin-add", "Add a new admin user to this account. Creates a device-level user entry (users.json) and adds them to this account's admins list (account.json). Generates a unique 4-digit PIN unless one is specified. Returns the userId and PIN to share with the new admin.", {
    name: z.string().describe("Display name for the new admin"),
    pin: z.string().optional().describe("Optional 4-digit PIN. If omitted, a unique PIN is generated."),
}, async ({ name, pin: rawPin }) => {
    const TAG = "[admin]";
    if (!name.trim()) {
        return { content: [{ type: "text", text: `${TAG} Name is required.` }], isError: true };
    }
    let users;
    try {
        users = readUsersJson();
    }
    catch (err) {
        return { content: [{ type: "text", text: `${TAG} ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    // Resolve the calling user's identity from the session environment
    const callerUserId = process.env.USER_ID;
    // PIN: use provided or generate
    let plaintextPin;
    if (rawPin) {
        if (rawPin.length < 4) {
            return { content: [{ type: "text", text: `${TAG} PIN must be at least 4 digits.` }], isError: true };
        }
        const hashed = hashPin(rawPin);
        if (users.some(u => u.pin === hashed)) {
            return { content: [{ type: "text", text: `${TAG} That PIN is already in use. Choose a different PIN.` }], isError: true };
        }
        plaintextPin = rawPin;
    }
    else {
        try {
            plaintextPin = generateUniquePin(users);
        }
        catch (err) {
            return { content: [{ type: "text", text: `${TAG} ${err instanceof Error ? err.message : String(err)}` }], isError: true };
        }
    }
    const pinHash = hashPin(plaintextPin);
    const userId = crypto.randomUUID();
    // 1. Write to users.json (device-level)
    users.push({ userId, name: name.trim(), pin: pinHash });
    try {
        writeUsersJson(users);
    }
    catch (err) {
        return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    // 2. Write to account.json (account-level)
    try {
        const config = await readAccountConfig();
        const admins = (config.admins ?? []);
        if (!admins.some(a => a.userId === userId)) {
            admins.push({ userId, role: "admin" });
            config.admins = admins;
            const configPath = join(getAccountDir(), "account.json");
            await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
        }
    }
    catch (err) {
        console.error(`${TAG} account.json write failed: ${err instanceof Error ? err.message : String(err)}`);
        return { content: [{ type: "text", text: `${TAG} User created in users.json but failed to add to account: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    // 3. Write to Neo4j (graph-level) — partial failure is a warning, not an error
    let neo4jWarning = "";
    try {
        const session = getSession();
        try {
            const createdAt = new Date().toISOString();
            const trimmedName = name.trim();
            await session.run(`MERGE (au:AdminUser {userId: $userId})
           ON CREATE SET au.name = $name, au.createdAt = $createdAt
           ON MATCH SET au.name = $name
           WITH au
           MATCH (b:LocalBusiness {accountId: $accountId})
           MERGE (au)-[r:ADMIN_OF]->(b)
           ON CREATE SET r.role = 'admin', r.grantedAt = $createdAt`, { userId, name: trimmedName, createdAt, accountId: ACCOUNT_ID });
        }
        finally {
            await session.close();
        }
    }
    catch (err) {
        const errMsg = err instanceof Error ? err.message : String(err);
        console.error(`${TAG} Neo4j sync failed during admin-add: userId=${userId} error=${errMsg} — files written, graph pending`);
        neo4jWarning = ` Note: Neo4j sync failed (${errMsg}) — the admin user is functional but the graph will need reconciliation on next seed.`;
    }
    console.error(`${TAG} admin added: userId=${userId} userName=${name.trim()} accountId=${ACCOUNT_ID} role=admin addedBy=${callerUserId ?? "unknown"}`);
    return {
        content: [{
                type: "text",
                text: `Admin added successfully.\n\n- **Name:** ${name.trim()}\n- **userId:** ${userId}\n- **PIN:** ${plaintextPin}\n- **Role:** admin\n\nShare the PIN with ${name.trim()} so they can log in.${neo4jWarning}`,
            }],
    };
});
server.tool("admin-remove", "Remove an admin from this account. Removes them from the account's admins list (account.json) and deletes the ADMIN_OF relationship in Neo4j. Does NOT remove the device-level user entry (they may admin other accounts). Cannot remove the last admin on the account.", {
    userId: z.string().describe("The userId of the admin to remove (use admin-list to find userIds)"),
}, async ({ userId }) => {
    const TAG = "[admin]";
    const callerUserId = process.env.USER_ID;
    // Read account.json and validate
    let config;
    let admins;
    try {
        config = await readAccountConfig();
        admins = (config.admins ?? []);
    }
    catch (err) {
        return { content: [{ type: "text", text: `${TAG} Failed to read account config: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    const targetIndex = admins.findIndex(a => a.userId === userId);
    if (targetIndex === -1) {
        return { content: [{ type: "text", text: `${TAG} User ${userId} is not an admin of this account.` }], isError: true };
    }
    if (admins.length <= 1) {
        return { content: [{ type: "text", text: `${TAG} Cannot remove the last admin. At least one admin must remain on the account.` }], isError: true };
    }
    // Resolve the admin's name for the confirmation message
    let removedName = userId;
    try {
        const users = readUsersJson();
        const user = users.find(u => u.userId === userId);
        if (user)
            removedName = user.name;
    }
    catch { /* name lookup is best-effort */ }
    // 1. Remove from account.json
    admins.splice(targetIndex, 1);
    config.admins = admins;
    try {
        const configPath = join(getAccountDir(), "account.json");
        await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
    }
    catch (err) {
        return { content: [{ type: "text", text: `${TAG} Failed to write account.json: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    // 2. Delete ADMIN_OF relationship in Neo4j — partial failure is a warning
    let neo4jWarning = "";
    try {
        const session = getSession();
        try {
            await session.run(`MATCH (au:AdminUser {userId: $userId})-[r:ADMIN_OF]->(b:LocalBusiness {accountId: $accountId})
           DELETE r`, { userId, accountId: ACCOUNT_ID });
        }
        finally {
            await session.close();
        }
    }
    catch (err) {
        const errMsg = err instanceof Error ? err.message : String(err);
        console.error(`${TAG} Neo4j sync failed during admin-remove: userId=${userId} error=${errMsg}`);
        neo4jWarning = ` Note: Neo4j relationship deletion failed (${errMsg}) — will be reconciled on next seed.`;
    }
    console.error(`${TAG} admin removed: userId=${userId} accountId=${ACCOUNT_ID} removedBy=${callerUserId ?? "unknown"}`);
    return {
        content: [{
                type: "text",
                text: `Removed ${removedName} from this account's admin list. Their PIN still works for any other accounts they administer.${neo4jWarning}`,
            }],
    };
});
server.tool("admin-list", "List all admins for this account with their names and roles.", {}, async () => {
    const TAG = "[admin]";
    let admins;
    try {
        const config = await readAccountConfig();
        admins = (config.admins ?? []);
    }
    catch (err) {
        return { content: [{ type: "text", text: `${TAG} Failed to read account config: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
    }
    if (admins.length === 0) {
        return { content: [{ type: "text", text: `${TAG} No admins configured for this account.` }] };
    }
    // Enrich with names from users.json
    let users = [];
    try {
        users = readUsersJson();
    }
    catch { /* name lookup is best-effort — show userIds if users.json is unavailable */ }
    const userMap = new Map(users.map(u => [u.userId, u.name]));
    const lines = admins.map(a => {
        const name = userMap.get(a.userId) ?? "(unknown)";
        return `- **${name}** — role: ${a.role}, userId: ${a.userId}`;
    });
    return {
        content: [{ type: "text", text: `Admins for this account:\n\n${lines.join("\n")}` }],
    };
});
server.tool("agent-image", "Upload, update, or remove an agent's image. action=set: copy the file at filePath to the agent's assets directory and update config.json with image URL and shape. action=remove: delete the image file and clear the image fields from config.json. imageShape: 'circle' for avatars/icons, 'rounded' for logos.", {
    agentSlug: z.string().describe("Agent slug (e.g. 'coaching', 'sales')"),
    action: z.enum(["set", "remove"]),
    filePath: z.string().optional().describe("Absolute path to the image file (required for action=set)"),
    imageShape: z.enum(["circle", "rounded"]).optional().describe("Image shape: circle for avatars, rounded for logos (required for action=set)"),
}, async ({ agentSlug, action, filePath, imageShape }) => {
    const VALID_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
    try {
        const accountDir = getAccountDir();
        const agentDir = resolve(accountDir, "agents", agentSlug);
        const configPath = resolve(agentDir, "config.json");
        if (!existsSync(configPath)) {
            return {
                content: [{ type: "text", text: `Agent '${agentSlug}' not found — no config.json at agents/${agentSlug}/` }],
                isError: true,
            };
        }
        if (action === "set") {
            if (!filePath) {
                return {
                    content: [{ type: "text", text: "filePath is required for action=set" }],
                    isError: true,
                };
            }
            if (!imageShape) {
                return {
                    content: [{ type: "text", text: "imageShape is required for action=set — use 'circle' for avatars/icons or 'rounded' for logos" }],
                    isError: true,
                };
            }
            if (!existsSync(filePath)) {
                return {
                    content: [{ type: "text", text: `File not found: ${filePath}` }],
                    isError: true,
                };
            }
            // Validate image extension
            const ext = filePath.toLowerCase().split(".").pop();
            if (!ext || !VALID_IMAGE_EXTENSIONS.includes(`.${ext}`)) {
                return {
                    content: [{ type: "text", text: `Not a valid image file. Supported formats: ${VALID_IMAGE_EXTENSIONS.join(", ")}` }],
                    isError: true,
                };
            }
            // Copy file to agent assets directory
            const assetsDir = resolve(agentDir, "assets");
            const { mkdirSync: mkdirSyncFs, copyFileSync, writeFileSync: writeFileSyncFs, renameSync } = await import("node:fs");
            mkdirSyncFs(assetsDir, { recursive: true });
            const filename = `agent-image.${ext}`;
            const destPath = resolve(assetsDir, filename);
            copyFileSync(filePath, destPath);
            // Atomic config update: read → modify → write temp → rename
            const configRaw = await readFile(configPath, "utf-8");
            let config;
            try {
                config = JSON.parse(configRaw);
            }
            catch {
                config = {};
            }
            config.image = `/agent-assets/${agentSlug}/${filename}`;
            config.imageShape = imageShape;
            const tmpPath = configPath + ".tmp";
            writeFileSyncFs(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
            renameSync(tmpPath, configPath);
            console.error(`[agent-config] ${agentSlug}: image=${config.image} imageShape=${imageShape} (set)`);
            return {
                content: [{ type: "text", text: `Image set for agent '${agentSlug}':\n  File: ${destPath}\n  URL: ${config.image}\n  Shape: ${imageShape}\n\nThe image is now accessible at ${config.image} and will appear in the public chat header.` }],
            };
        }
        // action === "remove"
        const configRaw = await readFile(configPath, "utf-8");
        let config;
        try {
            config = JSON.parse(configRaw);
        }
        catch {
            config = {};
        }
        // Delete the image file if it exists
        if (typeof config.image === "string") {
            const filename = config.image.split("/").pop();
            if (filename) {
                const imgPath = resolve(agentDir, "assets", filename);
                if (existsSync(imgPath)) {
                    const { unlinkSync } = await import("node:fs");
                    unlinkSync(imgPath);
                }
            }
        }
        // Remove image fields from config
        delete config.image;
        delete config.imageShape;
        const { writeFileSync: writeFileSyncRm, renameSync: renameSyncRm } = await import("node:fs");
        const tmpPath = configPath + ".tmp";
        writeFileSyncRm(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
        renameSyncRm(tmpPath, configPath);
        console.error(`[agent-config] ${agentSlug}: image removed`);
        return {
            content: [{ type: "text", text: `Image removed for agent '${agentSlug}'. The public chat header will fall back to the account brand logo.` }],
        };
    }
    catch (err) {
        console.error(`[agent-config] agent-image failed: ${err instanceof Error ? err.message : String(err)}`);
        return {
            content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("logs-read", "Read recent logs. type=system: raw Claude stream-json. type=session: SSE events sent to client. type=error: Claude subprocess stderr. type=heartbeat: platform event dispatcher (check-due-events cron). type=public: public agent diagnostic log (user messages, knowledge source, memory context, component events, responses). type=server: platform server log (session lifecycle, plugin loading, persistence, routing). type=mcp: MCP server stderr (Loop API calls, status codes, durations). type=vnc: consolidated VNC browser viewer lifecycle log (Xtigervnc/websockify/Chromium boot, HTTP viewer fetches, WebSocket upgrade decisions, proxy pipe open/close/bytes, MCP browser tool calls, client disconnect reasons, ensureVnc/ensureCdp recovery). For any 'browser' issue on the Pi this is the first-stop log. type=review: proactive log-review detector log (Task 385) — every scan cycle, rule match, suppression, rate-limit decision, admin-tool rule mutation, and watchdog event. Use this to audit whether the detector observed a known-bad condition. When sessionKey is provided, greps all log files (or just the specified type) for lines containing that session key and returns a unified timeline.", {
    type: z.enum(["system", "session", "error", "heartbeat", "public", "server", "mcp", "vnc", "review"]).optional(),
    lines: z.number().optional(),
    sessionKey: z.string().optional(),
}, async ({ type, lines = 50, sessionKey }) => {
    try {
        const LOG_DIR = resolve(getAccountDir(), "logs");
        if (!existsSync(LOG_DIR)) {
            return { content: [{ type: "text", text: `Log directory does not exist: ${LOG_DIR}` }] };
        }
        // --- Session-key filtered mode: grep across log files ---
        if (sessionKey) {
            const prefixes = {
                system: "claude-agent-stream-",
                error: "claude-agent-stderr-",
                session: "sse-events-",
                public: "public-agent-stream-",
                mcp: "mcp-",
            };
            // When a type is explicitly provided, search only that type's files.
            // When type is omitted, search all prefix-based log types (excludes heartbeat — no session context).
            // server, vnc, and review are single-file (configDir-scoped), so they have no prefix search;
            // their matches are produced by dedicated blocks below.
            const searchPrefixes = type && type !== "heartbeat" && type !== "server" && type !== "vnc" && type !== "review" && prefixes[type]
                ? { [type]: prefixes[type] }
                : (type === "server" || type === "vnc" || type === "review") ? {} : prefixes;
            const allFiles = readdirSync(LOG_DIR).filter(f => f.endsWith(".log"));
            const sections = [];
            // Search account-scoped prefix-based log files
            for (const [logType, prefix] of Object.entries(searchPrefixes)) {
                const typeFiles = allFiles
                    .filter(f => f.startsWith(prefix))
                    .sort(); // chronological by date suffix
                for (const file of typeFiles) {
                    const filePath = resolve(LOG_DIR, file);
                    try {
                        const result = execFileSync("grep", ["-F", sessionKey, filePath], { timeout: 5000 }).toString().trim();
                        if (result) {
                            sections.push(`## ${file} (${logType})\n${result}`);
                        }
                    }
                    catch (grepErr) {
                        // grep exits 1 when no lines match — that's not an error
                        const exitCode = grepErr.status;
                        if (exitCode !== 1)
                            throw grepErr;
                    }
                }
            }
            // Search server.log (platform-scoped, in configDir — not account-scoped)
            const includeServer = !type || type === "server";
            if (includeServer) {
                const serverLog = resolve(CONFIG_DIR, "logs", "server.log");
                if (existsSync(serverLog)) {
                    try {
                        const result = execFileSync("grep", ["-F", sessionKey, serverLog], { timeout: 5000 }).toString().trim();
                        if (result) {
                            sections.push(`## server.log (server)\n${result}`);
                        }
                    }
                    catch (grepErr) {
                        const exitCode = grepErr.status;
                        if (exitCode !== 1)
                            throw grepErr;
                    }
                }
            }
            // Search vnc-boot.log (platform-scoped, in configDir) — the
            // consolidated VNC browser viewer lifecycle log.
            const includeVnc = !type || type === "vnc";
            if (includeVnc) {
                const vncLog = resolve(CONFIG_DIR, "logs", "vnc-boot.log");
                if (existsSync(vncLog)) {
                    try {
                        const result = execFileSync("grep", ["-F", sessionKey, vncLog], { timeout: 5000 }).toString().trim();
                        if (result) {
                            sections.push(`## vnc-boot.log (vnc)\n${result}`);
                        }
                    }
                    catch (grepErr) {
                        const exitCode = grepErr.status;
                        if (exitCode !== 1)
                            throw grepErr;
                    }
                }
            }
            // Search review.log (platform-scoped, in configDir) — the
            // proactive log-review detector's self-log (Task 385).
            const includeReview = !type || type === "review";
            if (includeReview) {
                const reviewLog = resolve(CONFIG_DIR, "logs", "review.log");
                if (existsSync(reviewLog)) {
                    try {
                        const result = execFileSync("grep", ["-F", sessionKey, reviewLog], { timeout: 5000 }).toString().trim();
                        if (result) {
                            sections.push(`## review.log (review)\n${result}`);
                        }
                    }
                    catch (grepErr) {
                        const exitCode = grepErr.status;
                        if (exitCode !== 1)
                            throw grepErr;
                    }
                }
            }
            if (sections.length === 0) {
                return { content: [{ type: "text", text: `No log lines found for session key "${sessionKey}".` }] };
            }
            return { content: [{ type: "text", text: `# Session timeline: ${sessionKey}\n\n${sections.join("\n\n")}` }] };
        }
        // --- Standard mode: tail most recent file of the requested type ---
        const resolvedType = type ?? "system";
        // Heartbeat log is a single fixed file, not prefix-based
        if (resolvedType === "heartbeat") {
            const logFile = resolve(LOG_DIR, "check-due-events.log");
            if (!existsSync(logFile)) {
                return { content: [{ type: "text", text: "No heartbeat log found yet. The platform cron may not have run." }] };
            }
            const result = execFileSync("tail", ["-n", String(lines), logFile], {
                timeout: 5000,
            }).toString();
            return { content: [{ type: "text", text: `# check-due-events.log\n\n${result}` }] };
        }
        // Server log is a single fixed file in configDir (platform-scoped, not account-scoped)
        if (resolvedType === "server") {
            const serverLog = resolve(CONFIG_DIR, "logs", "server.log");
            if (!existsSync(serverLog)) {
                return { content: [{ type: "text", text: `No server log found at ${serverLog}` }] };
            }
            const result = execFileSync("tail", ["-n", String(lines), serverLog], {
                timeout: 5000,
            }).toString();
            return { content: [{ type: "text", text: `# server.log\n\n${result}` }] };
        }
        // VNC log is a single platform-scoped file covering the full
        // browser viewer lifecycle — see tool description for signals.
        if (resolvedType === "vnc") {
            const vncLogFile = resolve(CONFIG_DIR, "logs", "vnc-boot.log");
            if (!existsSync(vncLogFile)) {
                return { content: [{ type: "text", text: `No vnc-boot log found at ${vncLogFile}` }] };
            }
            const result = execFileSync("tail", ["-n", String(lines), vncLogFile], {
                timeout: 5000,
            }).toString();
            return { content: [{ type: "text", text: `# vnc-boot.log\n\n${result}` }] };
        }
        // Review log is a single platform-scoped file written by the in-process
        // review detector (Task 385). Every scan cycle, rule match, suppression,
        // rate-limit decision, admin-tool rule mutation, and watchdog event is
        // here. Use this to answer "did the detector see X?" questions.
        if (resolvedType === "review") {
            const reviewLogFile = resolve(CONFIG_DIR, "logs", "review.log");
            if (!existsSync(reviewLogFile)) {
                return { content: [{ type: "text", text: `No review log found at ${reviewLogFile} (detector may not have started yet)` }] };
            }
            const result = execFileSync("tail", ["-n", String(lines), reviewLogFile], {
                timeout: 5000,
            }).toString();
            return { content: [{ type: "text", text: `# review.log\n\n${result}` }] };
        }
        const prefix = resolvedType === "error" ? "claude-agent-stderr-"
            : resolvedType === "session" ? "sse-events-"
                : resolvedType === "public" ? "public-agent-stream-"
                    : resolvedType === "mcp" ? "mcp-"
                        : "claude-agent-stream-";
        // Find the most recently modified file with the matching prefix
        const match = readdirSync(LOG_DIR)
            .filter(f => f.startsWith(prefix) && f.endsWith(".log"))
            .map(f => ({ file: f, mtime: statSync(resolve(LOG_DIR, f)).mtimeMs }))
            .sort((a, b) => b.mtime - a.mtime)[0];
        if (!match) {
            return { content: [{ type: "text", text: `No log file found for type '${resolvedType}' in ${LOG_DIR}` }] };
        }
        const logFile = resolve(LOG_DIR, match.file);
        const result = execFileSync("tail", ["-n", String(lines), logFile], {
            timeout: 5000,
        }).toString();
        return { content: [{ type: "text", text: `# ${match.file}\n\n${result}` }] };
    }
    catch (err) {
        return { content: [{ type: "text", text: `Failed to read logs: ${err instanceof Error ? err.message : String(err)}` }] };
    }
});
server.tool("plugin-read", "Read a plugin definition (PLUGIN.md) or one of its reference files.", {
    pluginName: z.string().describe("Name of the plugin directory (e.g. 'sales', 'business-assistant')"),
    file: z.string().optional().describe("Specific file to read (e.g. 'references/pricing.md'). Defaults to PLUGIN.md."),
}, async ({ pluginName, file }) => {
    try {
        const resolvedFile = file ?? "PLUGIN.md";
        console.log(`[plugin-read] ${pluginName}/${resolvedFile}`);
        const pluginPath = resolve(PLATFORM_ROOT, "plugins", pluginName, resolvedFile);
        if (!existsSync(pluginPath)) {
            return {
                content: [{ type: "text", text: `Plugin file not found: ${pluginPath}` }],
                isError: true,
            };
        }
        const content = await readFile(pluginPath, "utf-8");
        return { content: [{ type: "text", text: content }] };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("render-component", "Render a pre-built UI component inline in the conversation. " +
    "Call this instead of describing a UI — the component handles input collection. " +
    "Wait for the user's response before continuing.", {
    name: z.string().describe("Component name from the UI suite (e.g. single-select, confirm, info-card, form, progress)"),
    data: z.record(z.string(), z.unknown()),
}, async () => ({ content: [{ type: "text", text: "rendered" }] }));
server.tool("session-reset", "Reset the current session. Compacts conversation history to memory, clears the visible conversation, " +
    "and starts a fresh session with a new greeting. Call when the user asks to start a new session, " +
    "clear the conversation, or start fresh.", {}, async () => ({ content: [{ type: "text", text: "reset" }] }));
server.tool("session-resume", "Resume a previous session. Loads the selected session's message history into the chat timeline " +
    "and routes new messages to that conversation. Call after the user selects a session from the " +
    "session-list results. Pass the conversationId from the selected session.", { conversationId: z.string().uuid().describe("The conversationId of the session to resume") }, async () => ({ content: [{ type: "text", text: "resumed" }] }));
server.tool("remote-auth-set-password", "NEVER collect a password in chat — passwords must not appear in conversation history. " +
    "Direct the user to set their remote access password in the browser at /__remote-auth/setup. " +
    "This tool exists only for admin recovery when the browser setup is unavailable. " +
    "Hashes with scrypt and writes to the platform's brand-specific config directory. " +
    "The remote access password protects the admin interface when accessed over the internet — " +
    "it has no effect on the public endpoint or the tunnel itself. " +
    "tunnel-enable gates on this password being configured (admin must not be exposed unauthenticated).", { password: z.string() }, async ({ password }) => {
    const trimmed = password.trim();
    // Validate strength — same rules as the web server's validatePasswordStrength
    const checks = [
        { label: "at least 8 characters", met: trimmed.length >= 8 },
        { label: "contains a number", met: /\d/.test(trimmed) },
        { label: "contains a special character", met: /[^A-Za-z0-9]/.test(trimmed) },
        { label: "no leading or trailing spaces", met: trimmed.length > 0 && trimmed === password.trim() },
    ];
    const failed = checks.filter((c) => !c.met);
    if (failed.length > 0) {
        return {
            content: [{ type: "text", text: `Password requirements not met: ${failed.map((c) => c.label).join(", ")}` }],
            isError: true,
        };
    }
    try {
        const { scrypt, randomBytes } = await import("node:crypto");
        const salt = randomBytes(32);
        const hash = await new Promise((res, rej) => {
            scrypt(trimmed, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
                if (err)
                    rej(err);
                else
                    res(key);
            });
        });
        const stored = `${salt.toString("hex")}:${hash.toString("hex")}`;
        const pwPath = REMOTE_PASSWORD_FILE;
        await writeFile(pwPath, stored, { mode: 0o600 });
        return { content: [{ type: "text", text: "Remote access password set." }] };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed to set password: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("api-key-store", "Validate and store an Anthropic API key. Key must start with sk-ant- and be at least 90 characters. " +
    "Writes to the platform's brand-specific config directory. Takes effect immediately for the public agent.", { apiKey: z.string() }, async ({ apiKey }) => {
    try {
        writeKey(apiKey);
        return { content: [{ type: "text", text: `API key stored at ${keyFilePath()}.` }] };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
            isError: true,
        };
    }
});
server.tool("api-key-verify", "Verify the stored Anthropic API key works by making a minimal probe call. " +
    "Returns one of: valid (key works, credits available), billing (key valid but credit balance too low — " +
    "user must add credits at console.anthropic.com/settings/billing), auth_error (key rejected — invalid or revoked), " +
    "missing (no key file found), error (could not verify — network issue or Anthropic outage). " +
    "Call this on greeting, during system checks, or after storing a new key.", {}, async () => {
    const result = await validateKey();
    return {
        content: [{
                type: "text",
                text: `Status: ${result.status}\n${result.message}`,
            }],
    };
});
// ===================================================================
// Onboarding tools
// ===================================================================
server.tool("onboarding-get", "Read the current onboarding state from the graph. Returns which setup steps " +
    "have been completed and their timestamps. On first call for an account, " +
    "migrates any existing onboardingStep from account.json to the graph and " +
    "removes it from the file. If Neo4j is unreachable, returns an error — " +
    "the agent should skip onboarding for this session and retry next time.", {}, async () => {
    try {
        const state = await getOnboardingState(getSession, ACCOUNT_ID, getAccountDir());
        return {
            content: [{
                    type: "text",
                    text: JSON.stringify(state, null, 2),
                }],
        };
    }
    catch (err) {
        return {
            content: [{
                    type: "text",
                    text: `Failed to read onboarding state: ${err instanceof Error ? err.message : String(err)}`,
                }],
            isError: true,
        };
    }
});
server.tool("onboarding-complete-step", "Mark an onboarding step as completed. Sets a completion timestamp on the step " +
    "and updates currentStep to the highest completed step. Idempotent — completing " +
    "an already-completed step preserves its original timestamp. Step must be 1–8.", {
    step: z.number().int().min(1).max(8).describe("The onboarding step number (1–8) to mark as completed"),
}, async ({ step }) => {
    try {
        const state = await completeOnboardingStep(getSession, ACCOUNT_ID, step);
        return {
            content: [{
                    type: "text",
                    text: JSON.stringify(state, null, 2),
                }],
        };
    }
    catch (err) {
        return {
            content: [{
                    type: "text",
                    text: `Failed to complete onboarding step: ${err instanceof Error ? err.message : String(err)}`,
                }],
            isError: true,
        };
    }
});
// ===================================================================
// Utility tools
// ===================================================================
server.tool("qr-generate", "Generate a QR code from text or a URL. Returns the QR code as a data URI (PNG) or saves to a file path.", {
    data: z.string().describe("The text or URL to encode in the QR code"),
    outputPath: z
        .string()
        .optional()
        .describe("File path to save the PNG. If omitted, returns a data URI."),
    width: z.number().optional().describe("Width in pixels (default 300)"),
}, async ({ data, outputPath, width }) => {
    try {
        const options = { width: width ?? 300, margin: 2 };
        if (outputPath) {
            await QRCode.toFile(outputPath, data, options);
            return {
                content: [
                    {
                        type: "text",
                        text: `QR code saved to ${outputPath}`,
                    },
                ],
            };
        }
        const dataUri = await QRCode.toDataURL(data, options);
        return {
            content: [
                {
                    type: "text",
                    text: `QR code generated for: ${data}`,
                },
                {
                    type: "image",
                    data: dataUri.replace(/^data:image\/png;base64,/, ""),
                    mimeType: "image/png",
                },
            ],
        };
    }
    catch (err) {
        return {
            content: [
                {
                    type: "text",
                    text: `QR generation failed: ${err instanceof Error ? err.message : String(err)}`,
                },
            ],
            isError: true,
        };
    }
});
// ===================================================================
// Premium plugin tools
// ===================================================================
server.tool("premium-list", "List available premium plugins and their delivery status. Returns structured data " +
    "for each premium plugin in the staging area: name, type (standalone/bundle), description, " +
    "purchase status, and delivery status (per sub-plugin for bundles).", {}, async () => {
    try {
        if (!existsSync(STAGING_ROOT)) {
            return {
                content: [{ type: "text", text: "No premium plugins available. The staging directory does not exist — premium plugins become available after the next platform update." }],
            };
        }
        const config = await readAccountConfig();
        const purchased = Array.isArray(config.purchasedPlugins) ? config.purchasedPlugins : [];
        const enabled = Array.isArray(config.enabledPlugins) ? config.enabledPlugins : [];
        const entries = readdirSync(STAGING_ROOT, { withFileTypes: true })
            .filter(e => e.isDirectory())
            .sort((a, b) => a.name.localeCompare(b.name));
        if (entries.length === 0) {
            return {
                content: [{ type: "text", text: "No premium plugins found in the staging area." }],
            };
        }
        const plugins = [];
        for (const entry of entries) {
            const stagingDir = resolve(STAGING_ROOT, entry.name);
            const bundlePath = join(stagingDir, "BUNDLE.md");
            const pluginPath = join(stagingDir, "PLUGIN.md");
            if (existsSync(bundlePath)) {
                // Bundle
                const fm = parseFrontmatter(readFileSync(bundlePath, "utf-8"));
                const subPlugins = (Array.isArray(fm.plugins) ? fm.plugins : [])
                    .filter(name => VALID_PLUGIN_NAME.test(name));
                const subStatus = subPlugins.map(name => ({
                    name,
                    delivered: existsSync(join(PLUGINS_DIR, name, "PLUGIN.md")),
                    enabled: enabled.includes(name),
                }));
                plugins.push({
                    name: entry.name,
                    type: "bundle",
                    description: typeof fm.description === "string" ? fm.description : "",
                    purchased: purchased.includes(entry.name),
                    subPlugins: subStatus,
                });
            }
            else if (existsSync(pluginPath)) {
                // Standalone
                const fm = parseFrontmatter(readFileSync(pluginPath, "utf-8"));
                plugins.push({
                    name: entry.name,
                    type: "standalone",
                    description: typeof fm.description === "string" ? fm.description : "",
                    purchased: purchased.includes(entry.name),
                    delivered: existsSync(join(PLUGINS_DIR, entry.name, "PLUGIN.md")),
                    enabled: enabled.includes(entry.name),
                });
            }
            // Skip directories that have neither BUNDLE.md nor PLUGIN.md
        }
        return {
            content: [{ type: "text", text: JSON.stringify(plugins, null, 2) }],
        };
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `Failed to list premium plugins: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
});
server.tool("premium-deliver", "Deliver a purchased premium plugin. Copies plugin files from the staging area to the active " +
    "plugin directory, verifies each copy, updates enabledPlugins in account.json, and scans for " +
    "agent templates. Handles both standalone plugins and bundles (auto-detected). Idempotent — " +
    "already-delivered sub-plugins are skipped. Returns a structured result with per-sub-plugin " +
    "status and available templates.", {
    pluginName: z.string().describe("Name of the premium plugin to deliver (e.g. 'real-agency', 'teaching')"),
}, async ({ pluginName }) => {
    const TAG = "[premium-deliver]";
    // --- Validate plugin name ---
    if (!pluginName || !VALID_PLUGIN_NAME.test(pluginName)) {
        return {
            content: [{ type: "text", text: `${TAG} Invalid plugin name "${pluginName}". Must match [a-z0-9-].` }],
            isError: true,
        };
    }
    // --- Guard against clobbering core plugins ---
    if (CORE_PLUGINS.includes(pluginName)) {
        return {
            content: [{ type: "text", text: `${TAG} "${pluginName}" is a core plugin and cannot be delivered as a premium plugin.` }],
            isError: true,
        };
    }
    // --- Check staging directory ---
    if (!existsSync(STAGING_ROOT)) {
        return {
            content: [{ type: "text", text: `${TAG} Premium plugins staging directory does not exist. Premium plugins become available after the next platform update.` }],
            isError: true,
        };
    }
    const stagingDir = resolve(STAGING_ROOT, pluginName);
    if (!existsSync(stagingDir)) {
        return {
            content: [{ type: "text", text: `${TAG} No premium plugin named "${pluginName}" found in the staging area.` }],
            isError: true,
        };
    }
    // --- Read account config ---
    let config;
    try {
        config = await readAccountConfig();
    }
    catch (err) {
        return {
            content: [{ type: "text", text: `${TAG} Failed to read account config: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
    const purchased = Array.isArray(config.purchasedPlugins) ? config.purchasedPlugins : [];
    // --- Check purchase status ---
    if (!purchased.includes(pluginName)) {
        return {
            content: [{ type: "text", text: `${TAG} "${pluginName}" is not in purchasedPlugins. Record the purchase first.` }],
            isError: true,
        };
    }
    // --- Detect type: bundle vs standalone ---
    const bundlePath = join(stagingDir, "BUNDLE.md");
    const pluginMdPath = join(stagingDir, "PLUGIN.md");
    const isBundle = existsSync(bundlePath);
    const isStandalone = !isBundle && existsSync(pluginMdPath);
    if (!isBundle && !isStandalone) {
        return {
            content: [{ type: "text", text: `${TAG} Staging directory "${pluginName}" contains neither BUNDLE.md nor PLUGIN.md. Invalid premium plugin.` }],
            isError: true,
        };
    }
    const items = [];
    if (isBundle) {
        const fm = parseFrontmatter(readFileSync(bundlePath, "utf-8"));
        const subPluginNames = Array.isArray(fm.plugins) ? fm.plugins : [];
        if (subPluginNames.length === 0) {
            return {
                content: [{ type: "text", text: `${TAG} BUNDLE.md for "${pluginName}" has an empty or missing plugins array.` }],
                isError: true,
            };
        }
        for (const sub of subPluginNames) {
            if (!VALID_PLUGIN_NAME.test(sub)) {
                return {
                    content: [{ type: "text", text: `${TAG} Sub-plugin name "${sub}" in BUNDLE.md contains invalid characters. Aborting delivery.` }],
                    isError: true,
                };
            }
            if (CORE_PLUGINS.includes(sub)) {
                return {
                    content: [{ type: "text", text: `${TAG} Sub-plugin "${sub}" collides with a core plugin name. Aborting delivery.` }],
                    isError: true,
                };
            }
            items.push({ name: sub, sourcePath: resolve(stagingDir, "plugins", sub) });
        }
    }
    else {
        items.push({ name: pluginName, sourcePath: stagingDir });
    }
    const results = [];
    const newlyEnabled = [];
    for (const item of items) {
        const destPath = resolve(PLUGINS_DIR, item.name);
        // Already delivered — skip (preserves user's enable/disable state)
        if (existsSync(join(destPath, "PLUGIN.md"))) {
            console.log(`${TAG} ${item.name} already delivered — skipping`);
            results.push({ name: item.name, status: "skipped" });
            continue;
        }
        // Validate source
        if (!existsSync(join(item.sourcePath, "PLUGIN.md"))) {
            const msg = `Source PLUGIN.md missing at ${item.sourcePath}`;
            console.error(`${TAG} FAILED ${item.name}: ${msg}`);
            results.push({ name: item.name, status: "failed", error: msg });
            continue;
        }
        // Copy
        try {
            cpSync(item.sourcePath, destPath, { recursive: true });
            console.log(`${TAG} copied ${item.name} from staging`);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`${TAG} FAILED to copy ${item.name}: ${msg}`);
            results.push({ name: item.name, status: "failed", error: msg });
            continue;
        }
        // Verify
        const verifyPath = join(destPath, "PLUGIN.md");
        if (!existsSync(verifyPath)) {
            console.error(`${TAG} FAILED to verify ${item.name}: PLUGIN.md missing after copy`);
            results.push({ name: item.name, status: "failed", error: "PLUGIN.md missing after copy" });
            continue;
        }
        // Count skills
        const skillsDir = join(destPath, "skills");
        let skillCount = 0;
        if (existsSync(skillsDir)) {
            skillCount = readdirSync(skillsDir, { withFileTypes: true })
                .filter(e => e.isDirectory()).length;
        }
        console.log(`${TAG} verified ${item.name} (${skillCount} skills)`);
        results.push({ name: item.name, status: "delivered", skills: skillCount });
        newlyEnabled.push(item.name);
    }
    // --- Update enabledPlugins (only add newly delivered, deduplicate) ---
    if (newlyEnabled.length > 0) {
        try {
            // Re-read config to reduce race window
            const freshConfig = await readAccountConfig();
            const freshEnabled = Array.isArray(freshConfig.enabledPlugins)
                ? freshConfig.enabledPlugins
                : [];
            const merged = [...new Set([...freshEnabled, ...newlyEnabled])];
            freshConfig.enabledPlugins = merged;
            const configPath = join(getAccountDir(), "account.json");
            await writeFile(configPath, JSON.stringify(freshConfig, null, 2) + "\n", "utf-8");
            console.log(`${TAG} updated enabledPlugins: added ${newlyEnabled.join(", ")}`);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`${TAG} FAILED to update account.json: ${msg}`);
            return {
                content: [{
                        type: "text",
                        text: `${TAG} Plugins were copied but account.json update failed: ${msg}\n\nDelivery results:\n${JSON.stringify(results, null, 2)}`,
                    }],
                isError: true,
            };
        }
    }
    const templates = [];
    // For bundles, templates live in the staging agents/ dir; for standalone, in the delivered plugin
    const agentsSearchDirs = [];
    if (isBundle) {
        const bundleAgentsDir = join(stagingDir, "agents");
        if (existsSync(bundleAgentsDir))
            agentsSearchDirs.push(bundleAgentsDir);
    }
    else {
        const pluginAgentsDir = join(PLUGINS_DIR, pluginName, "agents");
        if (existsSync(pluginAgentsDir))
            agentsSearchDirs.push(pluginAgentsDir);
    }
    for (const agentsDir of agentsSearchDirs) {
        const subdirs = readdirSync(agentsDir, { withFileTypes: true })
            .filter(e => e.isDirectory());
        for (const sub of subdirs) {
            const templateJsonPath = join(agentsDir, sub.name, "template.json");
            if (!existsSync(templateJsonPath))
                continue;
            try {
                const templateData = JSON.parse(readFileSync(templateJsonPath, "utf-8"));
                const identityExists = existsSync(join(agentsDir, sub.name, "IDENTITY.md"));
                const soulExists = existsSync(join(agentsDir, sub.name, "SOUL.md"));
                if (!identityExists || !soulExists) {
                    console.log(`${TAG} template ${sub.name} incomplete — missing ${!identityExists ? "IDENTITY.md" : "SOUL.md"}, skipping`);
                    continue;
                }
                templates.push({
                    directory: join(agentsDir, sub.name),
                    displayName: templateData.displayName ?? sub.name,
                    description: templateData.description ?? "",
                    suggestedSlug: templateData.suggestedSlug,
                });
            }
            catch {
                console.log(`${TAG} template ${sub.name} has invalid template.json, skipping`);
            }
        }
    }
    // --- Build structured result ---
    const delivered = results.filter(r => r.status === "delivered");
    const skipped = results.filter(r => r.status === "skipped");
    const failed = results.filter(r => r.status === "failed");
    const totalSkills = delivered.reduce((sum, r) => sum + (r.skills ?? 0), 0);
    const output = {
        pluginName,
        type: isBundle ? "bundle" : "standalone",
        delivered: delivered.map(r => ({ name: r.name, skills: r.skills })),
        skipped: skipped.map(r => r.name),
        failed: failed.map(r => ({ name: r.name, error: r.error })),
        totalDelivered: delivered.length,
        totalSkipped: skipped.length,
        totalFailed: failed.length,
        totalSkills,
        templates: templates.map(t => ({
            displayName: t.displayName,
            description: t.description,
            suggestedSlug: t.suggestedSlug,
            directory: t.directory,
        })),
    };
    return {
        content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
    };
});
// ---------------------------------------------------------------------------
// file-attach — copy a generated file into the attachment store for download
// ---------------------------------------------------------------------------
server.tool("file-attach", "Attach a file from the account directory for delivery to the user. " +
    "Copies the file into the attachment store and returns metadata for rendering " +
    "a download component. The file must be within the account directory tree. " +
    "After calling this tool, call render-component with name 'file-attachment' " +
    "and pass the returned metadata as data.", {
    filePath: z.string().describe("Absolute path to the file to attach"),
}, async ({ filePath }) => {
    const TAG = "[file-attach]";
    if (!filePath || !filePath.trim()) {
        return {
            content: [{ type: "text", text: `${TAG} filePath is required` }],
            isError: true,
        };
    }
    const accountDir = getAccountDir();
    // --- Path validation: must be within the account directory ---
    let resolvedPath;
    try {
        const { realpathSync: rp } = await import("node:fs");
        resolvedPath = rp(filePath);
        const accountResolved = rp(accountDir);
        if (!resolvedPath.startsWith(accountResolved + "/")) {
            const sanitised = filePath.replace(accountDir, "<account>/");
            console.error(`${TAG} REJECTED path=${sanitised} reason=outside_account_directory`);
            return {
                content: [{ type: "text", text: `${TAG} Access denied: file is outside the account directory.` }],
                isError: true,
            };
        }
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT") {
            console.error(`${TAG} ENOENT path=${filePath}`);
            return {
                content: [{ type: "text", text: `${TAG} File not found: ${filePath}` }],
                isError: true,
            };
        }
        console.error(`${TAG} path validation error: ${err instanceof Error ? err.message : String(err)}`);
        return {
            content: [{ type: "text", text: `${TAG} Failed to validate file path: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        };
    }
    // --- Store in attachment store ---
    const targetUrl = `http://127.0.0.1:${PLATFORM_PORT}/api/admin/file-attach`;
    try {
        const res = await fetch(targetUrl, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ filePath: resolvedPath, accountId: ACCOUNT_ID }),
        });
        const result = await res.json();
        if (!res.ok || result.error) {
            const msg = result.error || `HTTP ${res.status}`;
            console.error(`${TAG} store failed url=${targetUrl} status=${res.status} error=${msg}`);
            return {
                content: [{ type: "text", text: `${TAG} Failed to attach file: ${msg} (url=${targetUrl})` }],
                isError: true,
            };
        }
        console.error(`${TAG} attached path=${resolvedPath} size=${result.sizeBytes} mime=${result.mimeType} id=${result.attachmentId}`);
        return {
            content: [{
                    type: "text",
                    text: JSON.stringify({
                        attachmentId: result.attachmentId,
                        filename: result.filename,
                        sizeBytes: result.sizeBytes,
                        mimeType: result.mimeType,
                    }),
                }],
        };
    }
    catch (err) {
        const errMsg = err instanceof Error ? err.message : String(err);
        console.error(`${TAG} fetch failed url=${targetUrl} error=${errMsg}`);
        return {
            content: [{ type: "text", text: `${TAG} Failed to attach file: could not reach platform server at ${targetUrl} (${errMsg})` }],
            isError: true,
        };
    }
});
// Review cadence tools (Task 385) — list, suppress, add, remove, etc.
// Registered as the final tool bundle because they depend on CONFIG_DIR
// and ACCOUNT_ID being resolved above.
registerReviewTools(server, { configDir: CONFIG_DIR, accountId: ACCOUNT_ID });
// Cleanup on exit (SIGTERM for systemd service stops on Pi)
const cleanup = async () => {
    await closeDriver();
    process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
const transport = new StdioServerTransport();
await server.connect(transport);
//# sourceMappingURL=index.js.map