// Tool-surface resolver.
//
// Loaded once at manager boot from a directory walk of every
// platform/plugins/<name>/PLUGIN.md (plus premium-plugins/<bundle>/PLUGIN.md
// and premium-plugins/<bundle>/plugins/<sub>/PLUGIN.md). The frontmatter of
// each PLUGIN.md declares a list of tools; every tool entry must specify
// `name`, `publicAllowlist: <bool>`, and `adminAllowlist: <bool>`. The
// resolver produces three sets of qualified tool names. The prefix differs
// by spawn channel (Task 502):
//
//   - admin:  subset where adminAllowlist === true. LONG prefix
//             `mcp__plugin_<dir>_<dir>__<tool>` — admin spawns take platform
//             MCPs from installed plugins, which Claude Code serves long.
//   - public: subset where publicAllowlist === true. SHORT prefix
//             `mcp__<dir>__<tool>` — public agents keep the per-spawn
//             `--mcp-config` channel, which registers servers under the short
//             prefix. Server selection still extracts `<dir>` from these names.
//   - all:    every declared tool across every plugin (both `tools:` entries
//             and `hidden:` names), LONG prefix. Used by the boot-time drift
//             assertion that validates specialists' long-prefix `tools:` names.
//
// The third surface — specialists — is declared on each specialist's
// agent `.md` frontmatter (`tools: <comma-list>`), not here. That list
// is consumed by Claude Code per-subagent; this resolver does not gate
// it. The drift assertion (see manager boot in `index.ts`) verifies the
// listed tools resolve to something — either an `all`-member tool, a
// CC-native tool, or a known MCP-bridge namespace.
//
// A tool missing either allowlist field is a refusal-to-boot condition,
// not a silent default. The decision belongs at the tool's declaration
// site; defaulting either way hides the question from the writer.
//
// The parser uses a hand-rolled YAML-ish walker over the frontmatter block
// — there is no js-yaml dependency in this service today and the schema
// is intentionally narrow (a sequence of `- name: <s>` + `publicAllowlist:
// <bool>` + `adminAllowlist: <bool>` triples). Anything outside that
// shape is rejected loudly.
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { canonicalToolName, qualifyShortToolName } from './canonical-tool-names.js';
export class ToolSurfaceError extends Error {
    reason;
    detail;
    constructor(reason, detail) {
        super(`${reason}: ${detail}`);
        this.reason = reason;
        this.detail = detail;
        this.name = 'ToolSurfaceError';
    }
}
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
function extractFrontmatter(raw, pluginDir) {
    const m = raw.match(FRONTMATTER_RE);
    if (!m) {
        throw new ToolSurfaceError('frontmatter-missing', `${pluginDir}/PLUGIN.md has no '---' delimited frontmatter`);
    }
    return m[1];
}
/** Parse the tools: block out of a PLUGIN.md frontmatter body. Returns an
 *  empty array when the frontmatter has no tools: key or declares `tools: []`.
 *  Throws ToolSurfaceError on any malformed entry (missing name, missing or
 *  non-boolean publicAllowlist or adminAllowlist). */
export function parseToolsBlock(frontmatter, pluginDir) {
    const lines = frontmatter.split(/\r?\n/);
    let i = 0;
    while (i < lines.length) {
        const line = lines[i];
        // Skip blank lines and other top-level keys until we hit `tools:`.
        if (/^tools:\s*\[\s*\]\s*$/.test(line))
            return [];
        if (/^tools:\s*$/.test(line)) {
            i++;
            break;
        }
        i++;
    }
    if (i >= lines.length)
        return [];
    const out = [];
    let current = null;
    const flush = () => {
        if (!current)
            return;
        if (typeof current.name !== 'string' || current.name.length === 0) {
            throw new ToolSurfaceError('tool-name-missing', `${pluginDir}/PLUGIN.md has a tool entry with no name`);
        }
        if (typeof current.publicAllowlist !== 'boolean') {
            if (current.publicAllowlist === undefined) {
                throw new ToolSurfaceError('public-allowlist-missing', `${pluginDir}/${current.name} — every tool must declare publicAllowlist: <true|false>`);
            }
            throw new ToolSurfaceError('public-allowlist-not-boolean', `${pluginDir}/${current.name} — publicAllowlist must be a YAML boolean (true|false), got ${JSON.stringify(current.publicAllowlist)}`);
        }
        if (typeof current.adminAllowlist !== 'boolean') {
            if (current.adminAllowlist === undefined) {
                throw new ToolSurfaceError('admin-allowlist-missing', `${pluginDir}/${current.name} — every tool must declare adminAllowlist: <true|false>`);
            }
            throw new ToolSurfaceError('admin-allowlist-not-boolean', `${pluginDir}/${current.name} — adminAllowlist must be a YAML boolean (true|false), got ${JSON.stringify(current.adminAllowlist)}`);
        }
        out.push(current);
        current = null;
    };
    for (; i < lines.length; i++) {
        const line = lines[i];
        if (line.length === 0)
            continue;
        // A top-level key (no leading space) ends the tools block.
        if (/^[A-Za-z_]/.test(line))
            break;
        const newEntry = line.match(/^\s+-\s+name:\s*(.+?)\s*$/);
        if (newEntry) {
            flush();
            current = { name: newEntry[1] };
            continue;
        }
        // A list item that does NOT lead with `name:` is malformed — the entry
        // has no name field. Surface it loudly rather than silently ignoring.
        const orphanListItem = line.match(/^\s+-\s+(?!name:)/);
        if (orphanListItem) {
            throw new ToolSurfaceError('tool-name-missing', `${pluginDir}/PLUGIN.md tool entry lacks a name field`);
        }
        const publicAllow = line.match(/^\s+publicAllowlist:\s*(.+?)\s*$/);
        if (publicAllow && current) {
            const v = publicAllow[1].trim();
            if (v === 'true')
                current.publicAllowlist = true;
            else if (v === 'false')
                current.publicAllowlist = false;
            else
                current.publicAllowlist = v;
            continue;
        }
        const adminAllow = line.match(/^\s+adminAllowlist:\s*(.+?)\s*$/);
        if (adminAllow && current) {
            const v = adminAllow[1].trim();
            if (v === 'true')
                current.adminAllowlist = true;
            else if (v === 'false')
                current.adminAllowlist = false;
            else
                current.adminAllowlist = v;
            continue;
        }
        // Other per-entry fields (e.g. nested name on its own line, or unknown
        // keys we don't care about) are ignored — only name, publicAllowlist,
        // and adminAllowlist affect the resolver.
    }
    flush();
    return out;
}
/** Parse the `hidden:` block out of a PLUGIN.md frontmatter body. Returns
 *  the list of tool names declared as hidden — these tools are served by
 *  the plugin's MCP server but excluded from agent-visible enumerations.
 *  They count for the `all` surface so specialists may still name them
 *  in their `tools:` frontmatter without tripping the drift assertion. */
export function parseHiddenBlock(frontmatter) {
    const lines = frontmatter.split(/\r?\n/);
    let i = 0;
    while (i < lines.length) {
        if (/^hidden:\s*$/.test(lines[i])) {
            i++;
            break;
        }
        i++;
    }
    if (i >= lines.length)
        return [];
    const out = [];
    for (; i < lines.length; i++) {
        const line = lines[i];
        if (line.length === 0)
            continue;
        if (/^[A-Za-z_]/.test(line))
            break;
        const item = line.match(/^\s+-\s+(.+?)\s*$/);
        if (item)
            out.push(item[1]);
    }
    return out;
}
/** Parse the optional `mcp:` block out of a PLUGIN.md frontmatter body.
 *  Returns null when the frontmatter has no `mcp:` key. Throws on a malformed
 *  shape (missing command, args not a list, env not key:value pairs). The
 *  schema mirrors Claude Code's `.mcp.json`. Platform plugins anchor on
 *  ${PLATFORM_ROOT}; premium plugins anchor on ${PREMIUM_PLUGINS_ROOT}
 *  (the sibling `premium-plugins/` directory).
 *
 *    mcp:
 *      command: node
 *      args:
 *        - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
 *        - ${PLATFORM_ROOT}/plugins/memory/mcp/dist/index.js
 *      env:
 *        MCP_SPAWN_TEE_NAME: memory
 *        ACCOUNT_ID: ${ACCOUNT_ID}
 */
export function parseMcpBlock(frontmatter, pluginDir) {
    const lines = frontmatter.split(/\r?\n/);
    let i = 0;
    while (i < lines.length) {
        if (/^mcp:\s*$/.test(lines[i])) {
            i++;
            break;
        }
        i++;
    }
    if (i >= lines.length)
        return null;
    let command = null;
    const args = [];
    const env = {};
    let envHasEntry = false;
    let mode = 'top';
    for (; i < lines.length; i++) {
        const line = lines[i];
        if (line.length === 0)
            continue;
        // top-level key (no leading space) ends the mcp block
        if (/^[A-Za-z_]/.test(line))
            break;
        const topCommand = line.match(/^\s\s(command):\s*(.+?)\s*$/);
        const topArgsHeader = line.match(/^\s\s(args):\s*$/);
        const topEnvHeader = line.match(/^\s\s(env):\s*$/);
        if (topCommand) {
            command = topCommand[2];
            mode = 'top';
            continue;
        }
        if (topArgsHeader) {
            mode = 'args';
            continue;
        }
        if (topEnvHeader) {
            mode = 'env';
            continue;
        }
        if (mode === 'args') {
            const item = line.match(/^\s+-\s+(.+?)\s*$/);
            if (item) {
                args.push(item[1]);
                continue;
            }
        }
        if (mode === 'env') {
            const kv = line.match(/^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.+?)\s*$/);
            if (kv) {
                env[kv[1]] = kv[2];
                envHasEntry = true;
                continue;
            }
        }
    }
    if (command === null) {
        throw new ToolSurfaceError('mcp-command-missing', `${pluginDir}/PLUGIN.md mcp: block has no command`);
    }
    const desc = { command, args };
    if (envHasEntry)
        desc.env = env;
    return desc;
}
/** Walk a plugins root directory, parse every <name>/PLUGIN.md, and return
 *  the {admin, public, all, mcpServers} sets. Throws on the first malformed
 *  manifest — the caller (the manager boot) must exit non-zero so the
 *  operator sees the gap.
 *
 *  Invariant: a plugin that declares `tools:` with at least one entry MUST
 *  also declare an `mcp:` block. Without it the spawn would push
 *  `--allowed-tools mcp__<plugin>__*` for a server that was never registered
 *  with Claude Code; the tools would be unreachable and the operator would
 *  see "tool not found" rather than a boot-time gap. */
export function loadToolSurface(pluginsRoots) {
    const roots = Array.isArray(pluginsRoots) ? pluginsRoots : [pluginsRoots];
    for (const r of roots) {
        if (!existsSync(r))
            throw new ToolSurfaceError('plugins-root-missing', r);
    }
    const admin = new Set();
    const pub = new Set();
    const all = new Set();
    const mcpServers = new Map();
    // First-registration manifest path per plugin name. On a second hit we
    // pull the prior path back out so the enriched error can quote both.
    const manifestPaths = new Map();
    /** Process one candidate plugin directory. The plugin's MCP namespace
     *  is the directory basename; sub-plugins under a premium bundle use
     *  their own basename, not the bundle's. */
    const loadOne = (pluginsRoot, plugin) => {
        const pluginDir = join(pluginsRoot, plugin);
        try {
            if (!statSync(pluginDir).isDirectory())
                return;
        }
        catch {
            return;
        }
        const manifestPath = join(pluginDir, 'PLUGIN.md');
        if (!existsSync(manifestPath))
            return;
        const raw = readFileSync(manifestPath, 'utf-8');
        const frontmatter = extractFrontmatter(raw, plugin);
        const tools = parseToolsBlock(frontmatter, plugin);
        if (tools.length === 0)
            return;
        const mcp = parseMcpBlock(frontmatter, plugin);
        if (!mcp) {
            throw new ToolSurfaceError('mcp-block-missing', `${plugin}/PLUGIN.md declares tools but no mcp: block; spawns would --allowed-tools mcp__${plugin}__* with no registered server`);
        }
        if (mcpServers.has(plugin)) {
            // A second registration is always a real conflict: the premium-tree
            // flat-copy reaper deletes byte-identical duplicates before boot, so
            // anything reaching here is either a divergent operator override or a
            // genuine cross-root collision. Quote both paths and a sha-prefix per
            // file so the operator can decide which to remove.
            const priorPath = manifestPaths.get(plugin);
            if (!priorPath) {
                throw new ToolSurfaceError('mcp-plugin-duplicate-no-prior-path', `${plugin}: mcpServers has entry but manifestPaths is missing — invariant violated, both maps must be written together`);
            }
            const priorSha = createHash('sha256').update(readFileSync(priorPath)).digest('hex').slice(0, 8);
            const newSha = createHash('sha256').update(raw).digest('hex').slice(0, 8);
            throw new ToolSurfaceError('mcp-plugin-duplicate', `${plugin} declared by more than one plugins root: ${priorPath} (sha=${priorSha}) vs ${manifestPath} (sha=${newSha})`);
        }
        mcpServers.set(plugin, mcp);
        manifestPaths.set(plugin, manifestPath);
        for (const t of tools) {
            // Admin + specialist spawns take platform MCPs from installed plugins,
            // which Claude Code serves under the long prefix; `all` (drift surface)
            // and `admin` (admin `--allowed-tools`) bind to that canonical name.
            // Public agents keep the per-spawn `--mcp-config` channel, so the public
            // allowlist stays the short prefix the per-spawn `.mcp.json` registers.
            const canonical = canonicalToolName(plugin, t.name);
            all.add(canonical);
            if (t.adminAllowlist)
                admin.add(canonical);
            if (t.publicAllowlist)
                pub.add(qualifyShortToolName(plugin, t.name));
        }
        // Hidden tools share the plugin's MCP server but stay out of the
        // admin/public allowlists. They count for `all` so specialists may
        // name them without tripping the drift assertion.
        for (const h of parseHiddenBlock(frontmatter)) {
            all.add(canonicalToolName(plugin, h));
        }
    };
    for (const root of roots) {
        const entries = readdirSync(root).sort();
        for (const plugin of entries) {
            const pluginDir = join(root, plugin);
            // Walk the top-level plugin manifest (platform/plugins/<name>/PLUGIN.md
            // or premium-plugins/<bundle>/PLUGIN.md).
            loadOne(root, plugin);
            // A premium bundle may also nest sibling plugins under
            // `<bundle>/plugins/<sub>/PLUGIN.md`. The bundle itself is often a
            // wrapper without tools while the sub-plugins carry real namespaces
            // (loop, property-data under real-agent). The MCP namespace ends up
            // as `mcp__<sub>__*` — the bundle name does not appear in tool names.
            const subRoot = join(pluginDir, 'plugins');
            let subEntries;
            try {
                if (!statSync(subRoot).isDirectory())
                    continue;
                subEntries = readdirSync(subRoot).sort();
            }
            catch {
                continue;
            }
            for (const sub of subEntries) {
                loadOne(subRoot, sub);
            }
        }
    }
    return { admin, public: pub, all, mcpServers };
}
//# sourceMappingURL=tool-surface.js.map