/**
 * src/autonomy/skill-installer.ts
 *
 * Install a skill that was previously discovered by the GitHub-discovery
 * cron and approved by the owner via the escalation queue. The cycle:
 *
 *   1. skill-discovery cron stores candidates in kg_entities (kind=skill,
 *      install_status=discovered)
 *   2. Saturday skill-library-review cron escalates top candidates to the
 *      owner with recommendation + counterargument
 *   3. Owner resolves the escalation: approved → this module is invoked
 *      to actually fetch + install the SKILL.md
 *   4. Install: download SKILL.md from the GitHub raw URL → safety vet →
 *      write to <dataDir>/skills/<slug>.md → flip KG status to 'installed'
 *
 * The agent's `loadSkills()` already reads <dataDir>/skills/ alongside the
 * universal bundle, so an installed skill becomes match-eligible on the
 * next conversation turn — zero restart required.
 */
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { getDataDir } from '../utils/data-dir.js';
import { record as journalRecord } from './journal.js';
const log = createChildLogger('autonomy:skill-installer');
// Safety: only install from these hosts. Prevents the agent from being
// tricked into installing arbitrary content by a poisoned KG entry.
const TRUSTED_GITHUB_HOSTS = ['github.com', 'raw.githubusercontent.com'];
// Cap installed file size (a skill is rarely > 30KB; > 200KB is suspicious)
const MAX_SKILL_BYTES = 200_000;
/**
 * Try common SKILL.md locations on a GitHub repo and return the first that
 * resolves. Falls back to README.md if no SKILL.md exists.
 */
async function fetchSkillContent(repoUrl) {
    const m = repoUrl.match(/^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
    if (!m)
        return { ok: false, reason: 'not a github.com repo URL' };
    const [, owner, repo] = m;
    const candidates = [
        `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/SKILL.md`,
        `https://raw.githubusercontent.com/${owner}/${repo}/main/SKILL.md`,
        `https://raw.githubusercontent.com/${owner}/${repo}/master/SKILL.md`,
        `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`,
        `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
    ];
    for (const url of candidates) {
        try {
            const r = await fetch(url, {
                headers: { 'User-Agent': 'Muassis-Skill-Installer/1.1', 'Accept': 'text/plain, text/markdown' },
                signal: AbortSignal.timeout(15_000),
            });
            if (!r.ok)
                continue;
            const buf = await r.arrayBuffer();
            if (buf.byteLength > MAX_SKILL_BYTES) {
                return { ok: false, reason: `skill content too large (${buf.byteLength} bytes; cap ${MAX_SKILL_BYTES})` };
            }
            const content = new TextDecoder('utf-8').decode(new Uint8Array(buf));
            return { ok: true, content, via: url };
        }
        catch {
            continue;
        }
    }
    return { ok: false, reason: 'no SKILL.md or README.md found at HEAD/main/master' };
}
/**
 * Lightweight content vetting. We don't try to be exhaustive — just block
 * obvious red flags. The agent should only install from skills the owner
 * approved, so this is defense in depth.
 */
function passesContentVet(content) {
    // (Audit M10) Match call-site patterns, not bare words. The previous
    // `\bexec_command\b` rejected any skill that *mentioned* exec_command in
    // documentation. Now we look for call-site forms only.
    // Also expanded: hardcoded API keys, base64 decode pipes, embedded creds.
    const redFlags = [
        /\bexec_command\s*\(/i,
        /\bchild_process\s*\.\s*\w+\s*\(/i,
        /\b(?:spawn|fork|exec|execSync|spawnSync)\s*\(/,
        /\brequire\s*\(\s*['"]child_process['"]\s*\)/,
        /\bcurl\s+[^|]+\|\s*(?:bash|sh|zsh)\b/,
        /\bwget\s+[^|]+\|\s*(?:bash|sh)\b/,
        /(?:eval|Function)\s*\(\s*(?:atob|Buffer\.from)\b/,
        /\bnvapi-[A-Za-z0-9_-]{60,}/,
        /\bsk-[A-Za-z0-9_-]{40,}/, // OpenAI keys
        /\bxoxb-[A-Za-z0-9-]{40,}/, // Slack bot tokens
        /\bAKIA[0-9A-Z]{16}/, // AWS access keys
        /\bAIza[0-9A-Za-z_-]{35}/, // Google API keys
        /\bgithub_pat_[A-Za-z0-9_-]{40,}/, // GitHub personal access tokens
        /\bghp_[A-Za-z0-9]{36,}/, // GitHub classic tokens
        /\b(?:steal|exfiltrate|harvest|backdoor|reverse[\s-]shell)\b/i,
        // Code blocks containing dangerous keywords (markdown context)
        /```[\s\S]*?\b(?:rm\s+-rf\s+\/|chmod\s+777\s+\/)/,
    ];
    for (const re of redFlags) {
        const m = content.match(re);
        if (m) {
            return { ok: false, reason: `content contains suspicious pattern: ${m[0].slice(0, 50)}` };
        }
    }
    if (content.trim().length < 200) {
        return { ok: false, reason: 'content too short to be a real skill (<200 chars)' };
    }
    return { ok: true };
}
function slugifyName(fullName) {
    return fullName.toLowerCase()
        .replace(/[^a-z0-9-]/g, '-')
        .replace(/-+/g, '-')
        .replace(/^-|-$/g, '')
        .slice(0, 64);
}
/**
 * Install a skill directly from a GitHub repo URL or a raw markdown URL,
 * without going through the discovery cron. Used by the `skill_install_from_url`
 * tool so the agent can autonomously fetch a skill mid-conversation when
 * none of the existing 200+ catalog skills match the user's need.
 *
 * Accepts:
 *   - https://github.com/<owner>/<repo>          → tries SKILL.md → README.md
 *   - https://raw.githubusercontent.com/<…>.md   → fetches directly
 *   - https://gist.githubusercontent.com/<…>.md  → fetches directly
 *
 * Same safety vetting as cron-discovered installs. Writes to user skills dir
 * + creates a KG row tagged install_status='installed' so it shows up in
 * the dashboard graph.
 */
export async function installSkillFromUrl(tenantId, url, options = {}) {
    // (Audit M9) Reject URLs containing userinfo (`user@host`) before parsing
    // — `https://github.com@evil.com` parses as host=evil.com, but a naive
    // text check on the URL string before parsing would let this through.
    // We additionally validate that the parsed origin matches the input URL's
    // first authority component to catch any other URL-parser quirks.
    if (url.includes('@')) {
        return { ok: false, message: 'URL contains "@" — userinfo not allowed (use plain https://host/path)' };
    }
    let parsedHost = null;
    try {
        const u = new URL(url);
        parsedHost = u.host.replace(/^www\./, '');
        // Sanity: confirm the host appears literally in the original URL right
        // after the scheme. Defends against unusual parsing.
        const expected = `://${u.host}`;
        if (!url.toLowerCase().includes(expected.toLowerCase())) {
            return { ok: false, message: 'URL host mismatch — refusing to fetch' };
        }
    }
    catch {
        return { ok: false, message: `invalid URL: ${url}` };
    }
    const ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com', 'gist.githubusercontent.com'];
    if (!ALLOWED_HOSTS.includes(parsedHost)) {
        return { ok: false, message: `untrusted host: ${parsedHost}. Allowed: ${ALLOWED_HOSTS.join(', ')}` };
    }
    let content = '';
    let via = url;
    if (/\.md(\?|$)/i.test(url) || parsedHost.startsWith('raw.') || parsedHost.startsWith('gist.')) {
        try {
            const r = await fetch(url, {
                headers: { 'User-Agent': 'Muassis-Skill-Installer/1.1', Accept: 'text/plain, text/markdown' },
                signal: AbortSignal.timeout(15_000),
            });
            if (!r.ok)
                return { ok: false, message: `fetch returned ${r.status}` };
            const buf = await r.arrayBuffer();
            if (buf.byteLength > MAX_SKILL_BYTES) {
                return { ok: false, message: `content too large (${buf.byteLength} bytes)` };
            }
            content = new TextDecoder('utf-8').decode(new Uint8Array(buf));
        }
        catch (err) {
            return { ok: false, message: `fetch failed: ${err.message}` };
        }
    }
    else {
        const fetched = await fetchSkillContent(url);
        if (!fetched.ok)
            return { ok: false, message: fetched.reason };
        content = fetched.content;
        via = fetched.via;
    }
    const vet = passesContentVet(content);
    if (!vet.ok) {
        await journalRecord({
            tenantId,
            kind: 'skill_install_failed',
            action: options.name || url,
            target: url,
            outcome: 'failure',
            reason: `vet failed: ${vet.reason}`,
        });
        return { ok: false, message: `safety vet failed: ${vet.reason}` };
    }
    const inferredName = options.name || extractNameFromMarkdown(content) || urlToName(url);
    const slug = slugifyName(inferredName);
    const dir = join(getDataDir(), 'skills');
    const filePath = join(dir, `${slug}.md`);
    await mkdir(dir, { recursive: true });
    let finalContent = content;
    if (!finalContent.startsWith('---\n')) {
        const description = options.description || extractFirstParagraph(content) || 'Auto-installed skill';
        const frontmatter = [
            '---',
            `name: ${inferredName}`,
            `priority: 3`,
            `keywords: ${slug}, installed-skill`,
            `source_url: ${url}`,
            `installed_at: ${new Date().toISOString()}`,
            '---',
            '',
            `# ${inferredName}`,
            '',
            description,
            '',
        ].join('\n');
        finalContent = frontmatter + finalContent;
    }
    await writeFile(filePath, finalContent, { encoding: 'utf-8', mode: 0o644 });
    // Mirror in KG so the dashboard graph + lessons_learned overlay both work.
    const { randomUUID } = await import('node:crypto');
    const kgId = randomUUID();
    const nameNorm = inferredName.toLowerCase();
    const installedProps = {
        url,
        via,
        install_status: 'installed',
        installed_at: new Date().toISOString(),
        installed_path: filePath,
        installed_via: 'tool_call',
    };
    try {
        // Upsert: a candidate is often already in the KG as install_status
        // 'discovered' (from the discovery cron). A blind INSERT then collides on
        // idx_kg_entities_uniq (tenant+kind+name) — which left status stuck at
        // 'discovered' and made auto-install re-run every cycle. Flip the existing
        // row to 'installed' instead; only INSERT when there's no prior row.
        const { data: existing } = await supabase
            .from('kg_entities')
            .select('id, properties')
            .eq('tenant_id', tenantId)
            .eq('kind', 'skill')
            .eq('name_normalized', nameNorm)
            .maybeSingle();
        if (existing) {
            await supabase
                .from('kg_entities')
                .update({ properties: { ...(existing.properties ?? {}), ...installedProps }, updated_at: new Date().toISOString() })
                .eq('id', existing.id);
        }
        else {
            await supabase.from('kg_entities').insert({
                id: kgId,
                tenant_id: tenantId,
                kind: 'skill',
                type: 'Skill',
                name: inferredName,
                name_normalized: nameNorm,
                properties: installedProps,
                source_files: [],
                episode_count: 0,
            });
        }
    }
    catch (err) {
        log.debug({ err: err.message }, 'KG mirror upsert failed (skill still installed on disk)');
    }
    await journalRecord({
        tenantId,
        kind: 'skill_installed',
        action: inferredName,
        target: filePath,
        outcome: 'success',
        reason: `Installed from ${via} (${finalContent.length} bytes)`,
        metadata: { kg_entity_id: kgId, source_url: url, via },
    });
    // (Audit H9) Refresh the in-memory cache so the next loadSkills() picks
    // up the new file without a gateway restart.
    try {
        const { invalidateSkillsCache } = await import('../agent/skills.js');
        invalidateSkillsCache(`installed ${inferredName}`);
    }
    catch { /* best-effort */ }
    log.info({ tenantId, name: inferredName, filePath, via }, '✅ skill installed via tool call');
    return { ok: true, message: `installed: ${inferredName}`, installedPath: filePath };
}
function extractNameFromMarkdown(content) {
    const m = content.match(/^---\s*\nname:\s*([^\n]+)/);
    if (m)
        return m[1].trim();
    const h1 = content.match(/^#\s+(.+)$/m);
    if (h1)
        return h1[1].trim();
    return null;
}
function extractFirstParagraph(content) {
    const stripped = content.replace(/^---[\s\S]*?\n---\s*/, '').replace(/^#\s+.+\n/m, '');
    const para = stripped.split(/\n\n+/).find((p) => p.trim().length > 30);
    return para ? para.trim().slice(0, 240) : null;
}
function urlToName(url) {
    try {
        const u = new URL(url);
        const parts = u.pathname.split('/').filter(Boolean);
        if (parts.length >= 2)
            return `${parts[0]}-${parts[1]}`.replace(/\.md$/, '');
        return parts[0]?.replace(/\.md$/, '') || 'installed-skill';
    }
    catch {
        return 'installed-skill';
    }
}
/**
 * Install a skill that's stored in the KG as kind=skill,
 * install_status=discovered. Idempotent — re-installs are no-ops.
 */
export async function installSkill(tenantId, kgEntityId) {
    const { data: entity } = await supabase
        .from('kg_entities')
        .select('id, name, properties, kind')
        .eq('tenant_id', tenantId)
        .eq('id', kgEntityId)
        .maybeSingle();
    if (!entity)
        return { ok: false, message: `kg_entity ${kgEntityId} not found` };
    if (entity.kind !== 'skill')
        return { ok: false, message: `entity ${kgEntityId} is not a skill (kind=${entity.kind})` };
    const props = (entity.properties) ?? {};
    if (props.install_status === 'installed') {
        return { ok: true, message: `already installed: ${entity.name}` };
    }
    const url = props.url;
    if (!url)
        return { ok: false, message: 'kg_entity has no url property' };
    const parsedHost = (() => { try {
        return new URL(url).host;
    }
    catch {
        return null;
    } })();
    if (!parsedHost || !TRUSTED_GITHUB_HOSTS.includes(parsedHost.replace(/^www\./, ''))) {
        return { ok: false, message: `untrusted host: ${parsedHost}` };
    }
    const fetched = await fetchSkillContent(url);
    if (!fetched.ok) {
        await journalRecord({
            tenantId,
            kind: 'skill_install_failed',
            action: entity.name,
            target: url,
            outcome: 'failure',
            reason: fetched.reason,
        });
        return { ok: false, message: `fetch failed: ${fetched.reason}` };
    }
    const vet = passesContentVet(fetched.content);
    if (!vet.ok) {
        await journalRecord({
            tenantId,
            kind: 'skill_install_failed',
            action: entity.name,
            target: url,
            outcome: 'failure',
            reason: `vet failed: ${vet.reason}`,
        });
        return { ok: false, message: `safety vet failed: ${vet.reason}` };
    }
    const slug = slugifyName(entity.name);
    const dir = join(getDataDir(), 'skills');
    const filePath = join(dir, `${slug}.md`);
    await mkdir(dir, { recursive: true });
    // Inject Muassis-flavored frontmatter if the source SKILL.md doesn't have
    // one, so it's match-eligible by `loadSkills()` and citation-eligible.
    let content = fetched.content;
    if (!content.startsWith('---\n')) {
        const description = props.description ?? '';
        const keywords = (Array.isArray(props.topics) ? props.topics.join(', ') : '') || 'skill, مهارة, installed';
        const frontmatter = [
            '---',
            `name: ${entity.name}`,
            `priority: 3`,
            `keywords: ${keywords}, ${slug}, installed-skill`,
            `source_url: ${url}`,
            `installed_at: ${new Date().toISOString()}`,
            '---',
            '',
            `# ${entity.name}`,
            '',
            `${description}`,
            '',
            `> Auto-installed from GitHub on owner approval. Cite when invoked.`,
            '',
        ].join('\n');
        content = frontmatter + content;
    }
    await writeFile(filePath, content, { encoding: 'utf-8', mode: 0o644 });
    // Flip KG status
    await supabase
        .from('kg_entities')
        .update({
        properties: { ...props, install_status: 'installed', installed_at: new Date().toISOString(), installed_path: filePath },
        updated_at: new Date().toISOString(),
    })
        .eq('id', kgEntityId);
    await journalRecord({
        tenantId,
        kind: 'skill_installed',
        action: entity.name,
        target: filePath,
        outcome: 'success',
        reason: `Installed from ${fetched.via} (${fetched.content.length} bytes)`,
        metadata: { kg_entity_id: kgEntityId, source_url: url },
    });
    // (Audit H9) Cache invalidation on cron-flow installs too.
    try {
        const { invalidateSkillsCache } = await import('../agent/skills.js');
        invalidateSkillsCache(`installed via cron flow: ${entity.name}`);
    }
    catch { /* best-effort */ }
    log.info({ tenantId, name: entity.name, filePath }, '✅ skill installed');
    return { ok: true, message: `installed: ${entity.name}`, installedPath: filePath };
}
/**
 * List all KG skill entries, grouped by install_status. Used by the
 * Saturday skill-library-review cron + dashboard.
 */
export async function listSkillCandidates(tenantId) {
    const { data } = await supabase
        .from('kg_entities')
        .select('id, name, properties')
        .eq('tenant_id', tenantId)
        .eq('kind', 'skill');
    const out = {
        discovered: [],
        installed: [],
        bundled: [],
    };
    for (const r of (data ?? [])) {
        const status = r.properties?.install_status;
        if (status === 'installed')
            out.installed.push(r);
        else if (status === 'bundled')
            out.bundled.push(r);
        else
            out.discovered.push(r);
    }
    return out;
}
//# sourceMappingURL=skill-installer.js.map