// 0784 hotfix — local install of arbitrary skills via the eval-server.
//
// Mirrors the install-engine-routes pattern (localhost-only, SSE progress
// stream, hard-coded command name, no shell) but accepts an arbitrary skill
// identifier validated by the same SAFE_NAME regex used in SkillDetailPanel.
//
// Security model:
//   1. Localhost-only — req.socket.remoteAddress must be 127.0.0.1 / ::1.
//   2. SAFE_NAME regex — skill identifier MUST match /^[a-zA-Z0-9._@/\-]+$/
//      (same as SkillDetailPanel sanitizer). The validated string is passed
//      as a single argv entry to spawn("vskill", [...]) — never a shell.
//   3. Scope allowlist — "project" | "user" | "global" only.
//   4. Hard-coded command name "vskill" — no path injection.
//
// 0845 T-017: extended to accept agentIds[] for in-process multi-install
// dispatch. Backward-compatible: legacy `agent: string` or no agent field
// still goes through the CLI-spawn single-agent path (AC-US2-09).
//
// 0845 closure fix: server-side fallback resolves `parsedSkill` from the
// local skill registry (project / personal / plugin cache) when callers
// omit it on the multi-agent path. Keeps the API surface minimal — the
// frontend just sends `{ skill, agentIds, scope }`.
//
// Endpoints:
//   POST /api/studio/install-skill        { skill, scope, agentIds?, parsedSkill? } → 202 + { jobId }
//   GET  /api/studio/install-skill/:id/stream             SSE progress | done
import * as os from "node:os";
import { promises as fsPromises } from "node:fs";
import { join as pathJoin } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { sendJson, readBody } from "./router.js";
import { isLocalhost, mountSpawnStreamRoute, startSpawnJob, } from "./install-jobs.js";
import { initSSE, sendSSE, sendSSEDone } from "./sse-helpers.js";
import { installSkillToMultipleAgents, } from "../installer/multi-install.js";
import { getAgent } from "../agents/agents-registry.js";
import { locateSkill } from "../clone/skill-locator.js";
import { extractDescription } from "../installer/frontmatter.js";
import { collectSkillBundleFiles, sanitizeSkillBundleFiles, } from "../installer/bundle-files.js";
import { getDefaultKeychain } from "../lib/keychain.js";
import { ensureLockfile, writeLockfile } from "../lockfile/lockfile.js";
const SAFE_NAME = /^[a-zA-Z0-9._@/\-]+$/;
// Agent IDs are slug-shaped: lowercase alphanumeric + hyphens. Stricter
// than SAFE_NAME to reject `.`, `/`, `@`, and uppercase at the boundary
// (FR-007 — every entry in agentIds[] must pass this regex).
const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9-]*$/;
const VALID_SCOPES = new Set(["project", "user", "global"]);
const INSTALL_TIMEOUT_MS = 180_000; // 3 min — installs can be slow on cold caches.
const JOBS = new Map();
const MULTI_JOBS = new Map();
function buildArgs(skill, scope) {
    // No shell — every entry is a discrete argv element. Scope is from a
    // closed set; skill is regex-validated. Both flags map to vskill CLI.
    if (scope === "global" || scope === "user")
        return ["install", skill, "--global"];
    return ["install", skill, "--scope", scope];
}
function normalizeInstallScope(scope) {
    return scope === "project" ? "project" : "user";
}
function userLockDir() {
    return pathJoin(os.homedir(), ".agents");
}
function emitMultiJob(job, event, data) {
    job.pastEvents.push({ event, data });
    for (const sub of job.subscribers) {
        try {
            sub(event, data);
        }
        catch { /* subscriber dead — ignore */ }
    }
}
function isValidParsedSkill(s) {
    if (!s || typeof s !== "object")
        return false;
    const v = s;
    const requiredFieldsOk = typeof v.name === "string" &&
        typeof v.description === "string" &&
        typeof v.body === "string";
    if (!requiredFieldsOk)
        return false;
    try {
        sanitizeSkillBundleFiles(v.files);
    }
    catch {
        return false;
    }
    return true;
}
const SERVER_FALLBACK_FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
const SERVER_FALLBACK_NAME_RE = /^name:\s*(.+?)\s*$/m;
const SERVER_FALLBACK_VERSION_RE = /^version:\s*(.+?)\s*$/m;
const DEFAULT_PLATFORM_URL = "https://verified-skill.com";
function unquoteYaml(value) {
    const trimmed = value.trim();
    if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
        (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
        return trimmed.slice(1, -1);
    }
    return trimmed;
}
function parseRawSkillMd(raw, fallbackName, fallbackDescription) {
    const normalized = raw.replace(/^﻿/, "").replace(/\r\n/g, "\n");
    const fmMatch = normalized.match(SERVER_FALLBACK_FRONTMATTER_RE);
    let originalFrontmatter = "";
    let body = normalized;
    let nameFromFm;
    let version;
    let descriptionFromFm;
    if (fmMatch) {
        originalFrontmatter = fmMatch[1];
        body = fmMatch[2];
        const nameMatch = originalFrontmatter.match(SERVER_FALLBACK_NAME_RE);
        if (nameMatch)
            nameFromFm = unquoteYaml(nameMatch[1]);
        const versionMatch = originalFrontmatter.match(SERVER_FALLBACK_VERSION_RE);
        if (versionMatch)
            version = unquoteYaml(versionMatch[1]);
        const descMatch = originalFrontmatter.match(/^description:\s*(.+?)\s*$/m);
        if (descMatch)
            descriptionFromFm = unquoteYaml(descMatch[1]);
    }
    const name = nameFromFm || fallbackName;
    return {
        name,
        description: descriptionFromFm || fallbackDescription || extractDescription(body, name),
        body,
        originalFrontmatter,
        version,
    };
}
function stripIdentifierVersion(identifier) {
    const slash = identifier.lastIndexOf("/");
    const at = identifier.lastIndexOf("@");
    return at > slash ? identifier.slice(0, at) : identifier;
}
function platformApiPath(identifier) {
    const base = stripIdentifierVersion(identifier);
    const parts = base.split("/").filter(Boolean);
    if (parts.length !== 3)
        return null;
    return `/api/v1/skills/${parts.map(encodeURIComponent).join("/")}`;
}
function platformBaseUrl(opts) {
    const raw = opts?.platformBaseUrl || process.env.VSKILL_PLATFORM_URL || DEFAULT_PLATFORM_URL;
    return raw.replace(/\/$/, "");
}
function normalizePlatformSkill(body) {
    if (!body || typeof body !== "object")
        return null;
    const record = body;
    const candidate = record.skill && typeof record.skill === "object"
        ? record.skill
        : record;
    return {
        name: typeof candidate.name === "string" ? candidate.name : undefined,
        displayName: typeof candidate.displayName === "string" ? candidate.displayName : undefined,
        description: typeof candidate.description === "string" ? candidate.description : undefined,
        repoUrl: typeof candidate.repoUrl === "string" ? candidate.repoUrl : undefined,
        skillPath: typeof candidate.skillPath === "string" ? candidate.skillPath : undefined,
        ownerSlug: typeof candidate.ownerSlug === "string" ? candidate.ownerSlug : undefined,
        repoSlug: typeof candidate.repoSlug === "string" ? candidate.repoSlug : undefined,
        skillSlug: typeof candidate.skillSlug === "string" ? candidate.skillSlug : undefined,
    };
}
function normalizePlatformVersions(body) {
    if (!body || typeof body !== "object")
        return [];
    const versions = body.versions;
    if (!Array.isArray(versions))
        return [];
    return versions
        .filter((v) => Boolean(v) && typeof v === "object")
        .map((v) => ({
        version: typeof v.version === "string" ? v.version : undefined,
        gitSha: typeof v.gitSha === "string" ? v.gitSha : null,
    }));
}
function parseGitHubRepo(repoUrl) {
    if (!repoUrl)
        return null;
    try {
        const url = new URL(repoUrl);
        if (url.hostname !== "github.com")
            return null;
        const [owner, repoRaw] = url.pathname.replace(/^\/+/, "").split("/");
        const repo = repoRaw?.replace(/\.git$/, "");
        if (!owner || !repo)
            return null;
        return { owner, repo };
    }
    catch {
        return null;
    }
}
function isSafeSkillPath(skillPath) {
    if (!skillPath)
        return false;
    if (skillPath.startsWith("/") || skillPath.includes("\\"))
        return false;
    const parts = skillPath.split("/");
    if (parts.some((p) => !p || p === "." || p === ".."))
        return false;
    return parts[parts.length - 1] === "SKILL.md";
}
function readGitHubToken(opts) {
    if (opts?.githubTokenProvider)
        return opts.githubTokenProvider();
    try {
        return getDefaultKeychain().getGitHubToken();
    }
    catch {
        return null;
    }
}
async function fetchSkillMdFromGitHub(opts) {
    const repo = parseGitHubRepo(opts.repoUrl);
    if (!repo || !isSafeSkillPath(opts.skillPath))
        return null;
    const pathPart = opts.skillPath.split("/").map(encodeURIComponent).join("/");
    const refs = Array.from(new Set(opts.refs.filter(Boolean)));
    if (!refs.includes(""))
        refs.push("");
    for (const ref of refs) {
        const url = new URL(`https://api.github.com/repos/${repo.owner}/${repo.repo}/contents/${pathPart}`);
        if (ref)
            url.searchParams.set("ref", ref);
        const headers = {
            Accept: "application/vnd.github.raw",
            "User-Agent": "vskill-skill-studio",
        };
        if (opts.token)
            headers.Authorization = `Bearer ${opts.token}`;
        try {
            const res = await opts.fetchImpl(url.toString(), { headers });
            if (res.ok)
                return await res.text();
        }
        catch {
            // Try the next ref.
        }
    }
    return null;
}
async function resolveParsedSkillFromPlatform(identifier, opts) {
    const apiPath = platformApiPath(identifier);
    if (!apiPath)
        return null;
    const fetchImpl = opts?.fetchImpl ?? fetch;
    const baseUrl = platformBaseUrl(opts);
    let skill = null;
    try {
        const res = await fetchImpl(`${baseUrl}${apiPath}`, { headers: { Accept: "application/json" } });
        if (!res.ok)
            return null;
        skill = normalizePlatformSkill(await res.json());
    }
    catch {
        return null;
    }
    if (!skill?.repoUrl || !isSafeSkillPath(skill.skillPath))
        return null;
    let versions = [];
    try {
        const res = await fetchImpl(`${baseUrl}${apiPath}/versions`, { headers: { Accept: "application/json" } });
        if (res.ok)
            versions = normalizePlatformVersions(await res.json());
    }
    catch {
        versions = [];
    }
    const requestedVersion = (() => {
        const slash = identifier.lastIndexOf("/");
        const at = identifier.lastIndexOf("@");
        return at > slash ? identifier.slice(at + 1) : null;
    })();
    const selectedVersion = requestedVersion
        ? versions.find((v) => v.version === requestedVersion)
        : versions[0];
    const refs = [
        selectedVersion?.gitSha ?? "",
        "main",
        "master",
    ];
    const raw = await fetchSkillMdFromGitHub({
        repoUrl: skill.repoUrl,
        skillPath: skill.skillPath,
        refs,
        fetchImpl,
        token: readGitHubToken(opts),
    });
    if (!raw)
        return null;
    return parseRawSkillMd(raw, skill.skillSlug || skill.displayName || stripIdentifierVersion(identifier).split("/").pop() || "skill", skill.description);
}
/**
 * Server-side fallback: when the frontend posts a multi-agent install
 * without `parsedSkill`, locate the skill on disk (project / personal /
 * plugin cache via {@link locateSkill}), read its SKILL.md, and build a
 * minimal {@link ParsedSkill}. Keeps the POST payload trivial — the
 * frontend doesn't need to fetch, parse, and re-marshal SKILL.md itself.
 *
 * Returns `null` when no on-disk skill matches the identifier (the route
 * surfaces this as a 404 so the caller can retry with an explicit
 * `parsedSkill`).
 */
export async function resolveParsedSkillFromIdentifier(identifier, opts) {
    const cwd = opts?.cwd ?? process.cwd();
    const home = opts?.home ?? os.homedir();
    const matches = await locateSkill(identifier, { cwd, home });
    if (matches.length > 0) {
        const source = matches[0];
        const skillMdPath = pathJoin(source.skillDir, "SKILL.md");
        try {
            const raw = await fsPromises.readFile(skillMdPath, "utf-8");
            const parsed = parseRawSkillMd(raw, source.skillName);
            const files = await collectSkillBundleFiles(source.skillDir);
            return { ...parsed, files };
        }
        catch {
            return null;
        }
    }
    return resolveParsedSkillFromPlatform(identifier, opts);
}
function validateAgentIds(ids) {
    if (!Array.isArray(ids))
        return { ok: false, error: "agentIds must be an array" };
    if (ids.length === 0)
        return { ok: false, error: "agentIds[] cannot be empty" };
    const validated = [];
    for (const raw of ids) {
        if (typeof raw !== "string")
            return { ok: false, error: "agentIds[] entries must be strings" };
        const id = raw.trim();
        if (!SAFE_AGENT_ID.test(id))
            return { ok: false, error: `invalid agentId: ${raw}` };
        if (!getAgent(id))
            return { ok: false, error: `unknown agentId: ${id}` };
        validated.push(id);
    }
    return { ok: true, ids: validated };
}
function reconstructedSkillMd(skill) {
    const frontmatter = skill.originalFrontmatter.trim()
        ? skill.originalFrontmatter.trim()
        : [
            `name: ${skill.name}`,
            `description: ${JSON.stringify(skill.description)}`,
            skill.version ? `version: ${skill.version}` : null,
        ].filter(Boolean).join("\n");
    return `---\n${frontmatter}\n---\n\n${skill.body.replace(/^\n+/, "")}`;
}
function computeSha(content) {
    return createHash("sha256").update(content).digest("hex");
}
function sourceFromIdentifier(identifier) {
    const parts = stripIdentifierVersion(identifier).split("/").filter(Boolean);
    if (parts.length >= 2) {
        const [owner, repo, slug] = parts;
        return {
            source: slug
                ? `marketplace:${owner}/${repo}#${slug}`
                : `github:${owner}/${repo}`,
            sourceRepoUrl: `https://github.com/${owner}/${repo}`,
        };
    }
    return {
        source: `registry:${stripIdentifierVersion(identifier)}`,
    };
}
function writeMultiInstallLockfile(opts) {
    const installedAgentIds = opts.results
        .filter((result) => result.status === "installed")
        .map((result) => result.agentId);
    if (installedAgentIds.length === 0)
        return;
    const content = reconstructedSkillMd(opts.skill);
    const lockDir = opts.scope === "user" ? userLockDir() : opts.projectRoot;
    const lock = ensureLockfile(lockDir);
    const files = ["SKILL.md", ...Object.keys(opts.skill.files ?? {})].sort();
    lock.skills[opts.skill.name] = {
        version: opts.skill.version || "1.0.0",
        sha: computeSha(content),
        tier: "VERIFIED",
        installedAt: new Date().toISOString(),
        scope: opts.scope,
        files,
        ...sourceFromIdentifier(opts.identifier),
    };
    lock.agents = [...new Set([...(lock.agents || []), ...installedAgentIds])];
    writeLockfile(lock, lockDir);
}
async function runMultiInstallJob(opts) {
    const job = {
        id: randomUUID(),
        done: false,
        subscribers: new Set(),
        pastEvents: [],
    };
    MULTI_JOBS.set(job.id, job);
    // 0850 — terminal trace so users have visible evidence the install ran.
    console.log("[install] start", JSON.stringify({
        skill: opts.identifier,
        scope: opts.scope,
        agentIds: opts.agentIds,
        projectRoot: opts.projectRoot,
    }));
    // Run install async — caller returns the jobId synchronously, then
    // streams progress + final done event as agents complete.
    (async () => {
        try {
            const result = await installSkillToMultipleAgents({
                skill: opts.skill,
                agentIds: opts.agentIds,
                scope: opts.scope,
                projectRoot: opts.projectRoot,
            });
            for (const agentResult of result.agents) {
                console.log("[install] result", JSON.stringify({
                    skill: opts.identifier,
                    agentId: agentResult.agentId,
                    status: agentResult.status,
                    path: agentResult.detail,
                }));
                emitMultiJob(job, "result", agentResult);
            }
            writeMultiInstallLockfile({
                identifier: opts.identifier,
                skill: opts.skill,
                scope: opts.scope,
                projectRoot: opts.projectRoot,
                results: result.agents,
            });
            emitMultiJob(job, "done", {
                success: result.errorCount === 0,
                results: result.agents,
                installedCount: result.installedCount,
                exportedCount: result.exportedCount,
                errorCount: result.errorCount,
            });
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            emitMultiJob(job, "done", { success: false, error: message });
        }
        finally {
            job.done = true;
        }
    })();
    return job;
}
export function registerInstallSkillRoutes(router, root = process.cwd()) {
    // POST /api/studio/install-skill
    // Body shape (legacy single-agent CLI spawn):    { skill: string, scope: "project"|"user"|"global" }
    // Body shape (new multi-agent in-process):       { skill: string, scope, agentIds: string[], parsedSkill: ParsedSkill, projectRoot?: string }
    router.post("/api/studio/install-skill", async (req, res) => {
        if (!isLocalhost(req)) {
            sendJson(res, { error: "localhost-only endpoint" }, 403, req);
            return;
        }
        const body = (await readBody(req));
        const skill = typeof body?.skill === "string" ? body.skill.trim() : "";
        const scope = typeof body?.scope === "string" ? body.scope : "";
        if (!skill || !SAFE_NAME.test(skill)) {
            sendJson(res, { error: "invalid skill identifier" }, 400, req);
            return;
        }
        if (!VALID_SCOPES.has(scope)) {
            sendJson(res, { error: "invalid scope (must be project|user|global)" }, 400, req);
            return;
        }
        // 0845 T-017 (AC-US2-06): multi-agent in-process path.
        if (body.agentIds !== undefined) {
            const validation = validateAgentIds(body.agentIds);
            if (!validation.ok) {
                sendJson(res, { error: validation.error }, 400, req);
                return;
            }
            const normalizedScope = normalizeInstallScope(scope);
            // 0845 closure fix: server-side fallback resolves parsedSkill from
            // the local skill registry when callers omit it. Keeps the frontend
            // payload minimal — the browser doesn't need to fetch + parse
            // SKILL.md itself. Explicit `parsedSkill` in the body still wins so
            // out-of-tree skills (e.g. authoring flows) keep working.
            let parsedSkill;
            if (isValidParsedSkill(body.parsedSkill)) {
                parsedSkill = {
                    ...body.parsedSkill,
                    files: sanitizeSkillBundleFiles(body.parsedSkill.files),
                };
            }
            else if (body.parsedSkill !== undefined) {
                sendJson(res, {
                    error: "parsedSkill, when provided, must have shape { name, description, body }",
                }, 400, req);
                return;
            }
            else {
                const resolved = await resolveParsedSkillFromIdentifier(skill, { cwd: root });
                if (!resolved) {
                    sendJson(res, {
                        error: `parsedSkill omitted and could not locate skill "${skill}" in project/personal/plugin-cache; ` +
                            "either install the skill locally first or include parsedSkill: { name, description, body } in the request body",
                    }, 404, req);
                    return;
                }
                parsedSkill = resolved;
            }
            const projectRoot = typeof body.projectRoot === "string" && body.projectRoot
                ? body.projectRoot
                : root;
            const job = await runMultiInstallJob({
                identifier: skill,
                skill: parsedSkill,
                agentIds: validation.ids,
                scope: normalizedScope,
                projectRoot,
            });
            sendJson(res, {
                jobId: job.id,
                mode: "multi-agent",
                streamPath: `/api/studio/install-skill/multi/${encodeURIComponent(job.id)}/stream`,
            }, 202, req);
            return;
        }
        // 0845 T-017 (AC-US2-09): legacy single-agent CLI spawn — backward compat.
        const job = startSpawnJob({
            command: "vskill",
            args: buildArgs(skill, scope),
            meta: { skill, scope: scope },
            timeoutMs: INSTALL_TIMEOUT_MS,
            jobs: JOBS,
        });
        sendJson(res, { jobId: job.id }, 202, req);
    });
    // GET /api/studio/install-skill/:jobId/stream — legacy spawn-job stream.
    mountSpawnStreamRoute(router, "/api/studio/install-skill", JOBS);
    // 0845 T-017 — distinct stream for multi-install jobs (in-process).
    // Same SSE shape (event: progress | done) so the frontend can consume
    // both flavors with one EventSource subscription pattern.
    router.get("/api/studio/install-skill/multi/:jobId/stream", async (req, res, params) => {
        if (!isLocalhost(req)) {
            sendJson(res, { error: "localhost-only endpoint" }, 403, req);
            return;
        }
        const job = MULTI_JOBS.get(params.jobId);
        if (!job) {
            sendJson(res, { error: "unknown jobId" }, 404, req);
            return;
        }
        initSSE(res, req);
        for (const ev of job.pastEvents) {
            try {
                if (ev.event === "done")
                    sendSSEDone(res, ev.data);
                else
                    sendSSE(res, ev.event, ev.data);
            }
            catch { /* stream closed */ }
        }
        if (job.done) {
            try {
                res.end();
            }
            catch { /* already closed */ }
            return;
        }
        const subscriber = (event, data) => {
            try {
                if (event === "done")
                    sendSSEDone(res, data);
                else
                    sendSSE(res, event, data);
            }
            catch {
                job.subscribers.delete(subscriber);
            }
        };
        job.subscribers.add(subscriber);
        const cleanup = () => { job.subscribers.delete(subscriber); };
        req.on("close", cleanup);
        req.on("aborted", cleanup);
    });
}
export const __test__ = {
    JOBS,
    MULTI_JOBS,
    SAFE_NAME,
    SAFE_AGENT_ID,
    VALID_SCOPES,
    INSTALL_TIMEOUT_MS,
    buildArgs,
    normalizeInstallScope,
    validateAgentIds,
    isValidParsedSkill,
    resolveParsedSkillFromIdentifier,
    resolveParsedSkillFromPlatform,
    fetchSkillMdFromGitHub,
    runMultiInstallJob,
    writeMultiInstallLockfile,
};
//# sourceMappingURL=install-skill-routes.js.map