"use strict";
/**
 * Unified in-process MCP server for the Amplitude wizard.
 *
 * Provides tools that run locally (secret values never leave the machine):
 * - check_env_keys: Check which env var keys exist in a .env file
 * - set_env_values: Create/update env vars in a .env file
 * - detect_package_manager: Detect the project's package manager(s)
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WIZARD_TOOLS_SERVER_NAME = exports.WIZARD_TOOL_NAMES = exports.SHARED_COMMITTED_ENV_BASENAMES = exports.WIZARD_GITIGNORE_PATTERNS = exports.PREVIOUS_SETUP_REPORT_FILENAME = void 0;
exports.isAllowedSkillUrl = isAllowedSkillUrl;
exports.fetchSkillMenu = fetchSkillMenu;
exports.downloadSkill = downloadSkill;
exports.loadBundledSkillMenu = loadBundledSkillMenu;
exports.bundledSkillExists = bundledSkillExists;
exports.preStageSkills = preStageSkills;
exports.installBundledSkill = installBundledSkill;
exports.ensureWizardArtifactsIgnored = ensureWizardArtifactsIgnored;
exports.archiveSetupReportFile = archiveSetupReportFile;
exports.restoreSetupReportIfMissing = restoreSetupReportIfMissing;
exports.cleanupWizardArtifacts = cleanupWizardArtifacts;
exports.cleanupIntegrationSkills = cleanupIntegrationSkills;
exports.resolveEnvPath = resolveEnvPath;
exports.shouldSkipAutoGitignoreForEnvBasename = shouldSkipAutoGitignoreForEnvBasename;
exports.ensureGitignoreCoverage = ensureGitignoreCoverage;
exports.parseEnvKeys = parseEnvKeys;
exports.mergeEnvValues = mergeEnvValues;
exports.normalizeEventName = normalizeEventName;
exports.persistEventPlan = persistEventPlan;
exports.persistDashboard = persistDashboard;
exports.buildFallbackReport = buildFallbackReport;
exports.writeFallbackReportIfMissing = writeFallbackReportIfMissing;
exports.createWizardToolsServer = createWizardToolsServer;
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const adm_zip_1 = __importDefault(require("adm-zip"));
const zod_1 = require("zod");
const debug_1 = require("../utils/debug");
const atomic_write_1 = require("../utils/atomic-write");
const event_plan_parser_js_1 = require("./event-plan-parser.js");
const storage_paths_1 = require("../utils/storage-paths");
const ui_1 = require("../ui");
const index_1 = require("./observability/index");
const dashboard_open_url_1 = require("../utils/dashboard-open-url");
// Allow-listed hosts for remote skill downloads. The wizard ships skills
// from amplitude/context-hub via GitHub Releases; nothing else should ever
// be a download source. Any host not on this list — including raw IPs and
// HTTP URLs — is rejected before we touch the filesystem.
const ALLOWED_SKILL_HOSTS = new Set([
    'github.com',
    'objects.githubusercontent.com',
    'codeload.github.com',
]);
function isAllowedSkillUrl(rawUrl) {
    try {
        const u = new URL(rawUrl);
        if (u.protocol !== 'https:')
            return false;
        return ALLOWED_SKILL_HOSTS.has(u.hostname);
    }
    catch {
        return false;
    }
}
// ---------------------------------------------------------------------------
// Remote skill helpers — for future use with amplitude/context-hub releases.
// Currently unused; skills are bundled locally. Enable by setting SKILLS_URL
// env var (e.g. https://github.com/amplitude/context-hub/releases/latest/download).
// ---------------------------------------------------------------------------
/**
 * Bound on the remote skill-menu fetch. The wizard waits on this call before
 * the agent can run, so an unbounded fetch on a stuck CDN connection would
 * stall the entire setup. 15s comfortably covers a worst-case GitHub Releases
 * fetch but ensures we fall back to bundled skills instead of hanging.
 */
const SKILL_MENU_FETCH_TIMEOUT_MS = 15_000;
/**
 * Fetch the skill menu from a remote skills server (GitHub Releases).
 * Returns parsed data on success, `null` on failure (including timeout —
 * the caller falls back to bundled skills, so silently swallowing a slow
 * network is the correct behavior).
 */
async function fetchSkillMenu(skillsBaseUrl) {
    const menuUrl = `${skillsBaseUrl}/skill-menu.json`;
    (0, debug_1.logToFile)(`fetchSkillMenu: fetching from ${menuUrl}`);
    // Bound the request with an AbortController so a hung CDN connection
    // doesn't stall agent startup. Timer is always cleared in `finally`.
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), SKILL_MENU_FETCH_TIMEOUT_MS);
    try {
        const resp = await fetch(menuUrl, { signal: controller.signal });
        if (resp.ok) {
            const data = (await resp.json());
            (0, debug_1.logToFile)(`fetchSkillMenu: loaded (${Object.keys(data.categories).length} categories)`);
            return data;
        }
        (0, debug_1.logToFile)(`fetchSkillMenu: failed with HTTP ${resp.status}`);
        return null;
    }
    catch (err) {
        const isAbort = err instanceof Error &&
            (err.name === 'AbortError' ||
                err.code === 'ABORT_ERR');
        (0, debug_1.logToFile)(isAbort
            ? `fetchSkillMenu: timed out after ${SKILL_MENU_FETCH_TIMEOUT_MS}ms`
            : `fetchSkillMenu: error: ${err instanceof Error ? err.message : String(err)}`);
        return null;
    }
    finally {
        clearTimeout(timeoutId);
    }
}
/**
 * Download and extract a skill from a remote URL.
 * Installs to `<installDir>/.claude/skills/<id>/`.
 *
 * Hardened against three classes of local-attacker exploit:
 *
 * 1. **Symlink race** — the previous version wrote to a hardcoded path
 *    `/tmp/amplitude-skill-<id>.zip`. A local user could pre-create that
 *    path as a symlink to e.g. `~/.ampli.json`, and `curl -o` would follow
 *    the link and overwrite the OAuth tokens. We now use `mkdtempSync` to
 *    get a unique, mode-0700, unguessable temp directory.
 * 2. **Untrusted host** — the previous version downloaded from any URL the
 *    skill manifest contained. Skills are only ever published by
 *    amplitude/context-hub via GitHub Releases, so we allowlist the
 *    GitHub-owned hosts and reject anything else.
 * 3. **Zip-slip** — naive zip extractors will follow `../../../etc/passwd`
 *    entries straight out of the target dir. We extract into the scratch
 *    tmp dir first, then walk the result and reject any entry whose
 *    resolved real path escapes the scratch root.
 *
 * Cross-platform note: extraction goes through `adm-zip` rather than the
 * `unzip` CLI so this works on Windows (which has no `unzip` by default).
 * `adm-zip`'s API is sync, matching the rest of this function.
 */
function downloadSkill(skillEntry, installDir) {
    const { execFileSync } = require('child_process');
    const skillDir = path_1.default.join(installDir, '.claude', 'skills', skillEntry.id);
    if (!isAllowedSkillUrl(skillEntry.downloadUrl)) {
        const msg = `downloadSkill: refused untrusted URL: ${skillEntry.downloadUrl}`;
        (0, debug_1.logToFile)(msg);
        return {
            success: false,
            error: 'Skill download URL is not from an allowed host',
        };
    }
    // Unique unguessable scratch dir (mode 0700) — defeats /tmp symlink races.
    // (Uses os.tmpdir() so this works on Windows too — PR 333.)
    const tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'amplitude-skill-'));
    const tmpFile = path_1.default.join(tmpDir, 'skill.zip');
    const extractDir = path_1.default.join(tmpDir, 'extract');
    try {
        fs_1.default.mkdirSync(extractDir, { recursive: true });
        execFileSync('curl', [
            '-sSfL', // -f: fail on HTTP errors; -S: show errors; -L: follow redirects
            '--proto',
            '=https',
            '--max-time',
            '30',
            skillEntry.downloadUrl,
            '-o',
            tmpFile,
        ]);
        // Extract into the scratch dir, NOT directly into the target. This way
        // any zip-slip entry lands somewhere inside `extractDir` (or fails the
        // realpath check below), never inside the user's project.
        //
        // We use `adm-zip` instead of shelling out to the `unzip` CLI because
        // Windows has no `unzip` binary by default, and the previous shell-out
        // ENOENT'd for every Windows user. `adm-zip` is pure JS, sync, and
        // does its own internal zip-slip filtering — but the realpath walker
        // below remains as defense-in-depth (different code, different bugs).
        const zip = new adm_zip_1.default(tmpFile);
        // `maintainEntryPath = true` preserves directory structure;
        // `overwrite = true` matches the previous `unzip -o` semantics.
        zip.extractAllTo(extractDir, /* overwrite */ true);
        // Defense-in-depth zip-slip check: walk every extracted entry and make
        // sure its real path stays inside extractDir. `unzip` is supposed to
        // refuse `../` paths since 6.0, but we don't trust that — version skew
        // and symlink entries (which `unzip` happily creates by default) make
        // it cheap to verify.
        const extractRealRoot = fs_1.default.realpathSync(extractDir);
        const walk = (dir) => {
            for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
                const full = path_1.default.join(dir, entry.name);
                // Use lstat so we catch symlinks pointing outside without following
                // them.
                const stat = fs_1.default.lstatSync(full);
                if (stat.isSymbolicLink()) {
                    // Resolve the link target relative to the link's own directory.
                    const resolved = path_1.default.resolve(dir, fs_1.default.readlinkSync(full));
                    if (resolved !== extractRealRoot &&
                        !resolved.startsWith(extractRealRoot + path_1.default.sep)) {
                        throw new Error(`Zip-slip detected: symlink ${full} -> ${resolved} escapes ${extractRealRoot}`);
                    }
                }
                else {
                    const real = fs_1.default.realpathSync(full);
                    if (real !== extractRealRoot &&
                        !real.startsWith(extractRealRoot + path_1.default.sep)) {
                        throw new Error(`Zip-slip detected: ${full} resolves to ${real}, outside ${extractRealRoot}`);
                    }
                    if (entry.isDirectory())
                        walk(full);
                }
            }
        };
        walk(extractDir);
        // Move into the final location only after we've validated the contents.
        fs_1.default.mkdirSync(skillDir, { recursive: true });
        for (const entry of fs_1.default.readdirSync(extractDir)) {
            const src = path_1.default.join(extractDir, entry);
            const dest = path_1.default.join(skillDir, entry);
            // Remove any pre-existing file at dest so renameSync succeeds across
            // file types (matches old `unzip -o` overwrite semantics).
            try {
                fs_1.default.rmSync(dest, { recursive: true, force: true });
            }
            catch {
                /* ignore */
            }
            fs_1.default.renameSync(src, dest);
        }
        (0, debug_1.logToFile)(`downloadSkill: installed ${skillEntry.id} from ${skillEntry.downloadUrl}`);
        return { success: true };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`downloadSkill: error: ${msg}`);
        return { success: false, error: msg };
    }
    finally {
        // Always clean up the scratch directory — never leave half-extracted
        // attacker-controlled bytes lying around in /tmp.
        try {
            fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
        }
        catch {
            /* ignore cleanup errors */
        }
    }
}
// ---------------------------------------------------------------------------
// Bundled skill helpers — reads skills from wizard/skills/integration/
// ---------------------------------------------------------------------------
/**
 * Resolve the bundled skills root directory.
 * Skills are shipped in `<wizardRoot>/skills/<category>/` subdirectories.
 */
function getSkillsRootDir() {
    // Walk up from this file to find the wizard repo root (where skills/ lives)
    let dir = __dirname;
    for (let i = 0; i < 5; i++) {
        if (fs_1.default.existsSync(path_1.default.join(dir, 'skills'))) {
            return path_1.default.join(dir, 'skills');
        }
        dir = path_1.default.dirname(dir);
    }
    // Fallback: relative to cwd
    return path_1.default.join(process.cwd(), 'skills');
}
/**
 * Build skill menu from bundled skill directories.
 * Scans skills/<category>/ subdirectories for folders containing SKILL.md.
 * Each subdirectory of skills/ becomes a category (e.g. integration, instrumentation).
 */
function loadBundledSkillMenu() {
    const skillsRoot = getSkillsRootDir();
    (0, debug_1.logToFile)(`loadBundledSkillMenu: scanning ${skillsRoot}`);
    const categories = {};
    try {
        for (const category of fs_1.default.readdirSync(skillsRoot)) {
            const categoryPath = path_1.default.join(skillsRoot, category);
            if (!fs_1.default.statSync(categoryPath).isDirectory())
                continue;
            const entries = [];
            for (const name of fs_1.default.readdirSync(categoryPath)) {
                const skillPath = path_1.default.join(categoryPath, name);
                const skillMd = path_1.default.join(skillPath, 'SKILL.md');
                if (fs_1.default.statSync(skillPath).isDirectory() && fs_1.default.existsSync(skillMd)) {
                    // Extract display name from SKILL.md frontmatter
                    const content = fs_1.default.readFileSync(skillMd, 'utf8');
                    const descMatch = content.match(/^description:\s*>-?\s*\n\s+(.+)/m);
                    const fallbackName = name
                        .replace(new RegExp(`^${category}-`), '')
                        .replace(/-/g, ' ');
                    const displayName = descMatch ? descMatch[1].trim() : fallbackName;
                    entries.push({ id: name, name: displayName, downloadUrl: '' });
                }
            }
            if (entries.length > 0) {
                categories[category] = entries;
            }
        }
    }
    catch (err) {
        (0, debug_1.logToFile)(`loadBundledSkillMenu: error scanning: ${err instanceof Error ? err.message : String(err)}`);
    }
    const total = Object.values(categories).reduce((sum, entries) => sum + entries.length, 0);
    (0, debug_1.logToFile)(`loadBundledSkillMenu: found ${total} skills across ${Object.keys(categories).length} categories`);
    return { categories };
}
/**
 * Strict skill-id allowlist: lowercase alphanumeric with hyphens or underscores
 * only. Used to gate any path.join with a skillId so a hostile or malformed
 * id can never escape the skills root via traversal characters (`..`, `/`).
 */
const SKILL_ID_ALLOWLIST = /^[a-z0-9][a-z0-9_-]*$/;
/**
 * Check whether a bundled skill exists on disk by searching across all
 * category subdirectories under skills/. Used to decide whether to pre-stage
 * a skill before the agent runs vs leave integration entry to the agent
 * prompt's on-disk discovery path (`Glob` under `.claude/skills/` — the
 * wizard-tools skill menu tools stay disabled).
 */
function bundledSkillExists(skillId) {
    // Reject any skillId that's not a strict basename — defense in depth before
    // the path.join below (skillId comes from internal callers but we treat it
    // as untrusted at the boundary).
    if (!SKILL_ID_ALLOWLIST.test(skillId))
        return false;
    const skillsRoot = getSkillsRootDir();
    try {
        for (const category of fs_1.default.readdirSync(skillsRoot)) {
            // Same defense for category names read off disk.
            if (!SKILL_ID_ALLOWLIST.test(category))
                continue;
            // skillId and category are both validated against SKILL_ID_ALLOWLIST
            // above, so neither can contain `..` or path separators.
            // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
            const candidate = path_1.default.join(skillsRoot, category, skillId);
            if (fs_1.default.existsSync(candidate) &&
                fs_1.default.statSync(candidate).isDirectory() &&
                // candidate is derived solely from the validated inputs above.
                // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
                fs_1.default.existsSync(path_1.default.join(candidate, 'SKILL.md'))) {
                return true;
            }
        }
    }
    catch {
        return false;
    }
    return false;
}
/**
 * Pre-stage a deterministic set of skills into the user's `.claude/skills/`
 * directory before the agent runs, so the agent can load them via the Skill
 * tool without having to call load_skill_menu / install_skill in a loop.
 *
 * The constant skills (taxonomy + instrumentation + dashboard) are always
 * the same; the integration skill is resolved per framework via the optional
 * resolver and may be null if no matching skill exists on disk.
 *
 * Returns the list of skill IDs that were successfully staged.
 */
function preStageSkills(installDir, integrationSkillId) {
    const constantSkills = [
        'wizard-prompt-supplement',
        'amplitude-quickstart-taxonomy-agent',
        'add-analytics-instrumentation',
        'amplitude-chart-dashboard-plan',
    ];
    const staged = [];
    for (const id of constantSkills) {
        if (!bundledSkillExists(id)) {
            (0, debug_1.logToFile)(`preStageSkills: skipping ${id} — not bundled`);
            continue;
        }
        const result = installBundledSkill(id, installDir);
        if (result.success) {
            staged.push(id);
        }
        else {
            (0, debug_1.logToFile)(`preStageSkills: failed to stage ${id}: ${result.error}`);
        }
    }
    let integrationStaged = false;
    if (integrationSkillId && bundledSkillExists(integrationSkillId)) {
        const result = installBundledSkill(integrationSkillId, installDir);
        if (result.success) {
            staged.push(integrationSkillId);
            integrationStaged = true;
        }
        else {
            (0, debug_1.logToFile)(`preStageSkills: failed to stage integration skill ${integrationSkillId}: ${result.error}`);
        }
    }
    (0, debug_1.logToFile)(`preStageSkills: staged [${staged.join(', ')}]`);
    return { staged, integrationStaged };
}
/**
 * Install a bundled skill by copying it to the project's .claude/skills/ dir.
 * Searches across all category subdirectories under skills/.
 */
function installBundledSkill(skillId, installDir) {
    const skillsRoot = getSkillsRootDir();
    const dest = path_1.default.join(installDir, '.claude', 'skills', skillId);
    try {
        for (const category of fs_1.default.readdirSync(skillsRoot)) {
            const src = path_1.default.join(skillsRoot, category, skillId);
            if (fs_1.default.existsSync(src) && fs_1.default.statSync(src).isDirectory()) {
                fs_1.default.cpSync(src, dest, { recursive: true });
                (0, debug_1.logToFile)(`installBundledSkill: copied ${skillId} from ${src} to ${dest}`);
                return { success: true };
            }
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`installBundledSkill: error: ${msg}`);
        return { success: false, error: msg };
    }
    return { success: false, error: `Bundled skill "${skillId}" not found` };
}
/**
 * Filename of the single archived prior report kept in the install dir.
 * Hoisted above WIZARD_GITIGNORE_PATTERNS so the array initializer can
 * reference it — without this hoist, the temporal-dead-zone forces a
 * duplicate string literal which silently drifts. Exported for tests
 * and OutroScreen so the constant doesn't drift downstream either.
 */
exports.PREVIOUS_SETUP_REPORT_FILENAME = 'amplitude-setup-report.previous.md';
/**
 * Patterns the wizard writes into the user's project that should never be
 * committed to git. Kept as a const so `ensureWizardArtifactsIgnored` has a
 * single source of truth for what to add to the user's .gitignore.
 *
 * The list is broader than what `cleanupWizardArtifacts` removes on exit:
 * several entries are kept on disk on purpose (the user-facing setup report,
 * canonical `.amplitude/` metadata, optional legacy dotfiles from older runs)
 * but selected paths should still never be committed.
 *
 * Notes on each entry:
 *   - `.amplitude/dashboard.json` — dashboard URL; often treated as
 *     machine-local. Other `.amplitude/*` files (`events.json`,
 *     `project-binding.json`) are intentionally not gitignored here so teams
 *     can commit metadata when they want.
 *   - `.amplitude-events.json` / `.amplitude-dashboard.json` — legacy paths
 *     from older wizard or skill versions. The wizard writes the canonical
 *     plan under `.amplitude/events.json` (see `persistEventPlan`) but still
 *     reads these when present; keep them gitignored so stray copies never get
 *     committed.
 *   - `amplitude-setup-report.previous.md` — wizard-managed archive of the
 *     prior run's setup report. The CURRENT report
 *     (`amplitude-setup-report.md`) is intentionally NOT gitignored —
 *     many users want to commit it as part of their analytics docs. Only
 *     the archived prior copy is a wizard implementation detail. (PR 316.)
 *   - `.claude/skills/integration-...` — single-use SDK-setup workflows;
 *     removed at end of run. (Pattern is `integration-...slash` in gitignore.)
 *   - The instrumentation/taxonomy skills are kept on disk so users can
 *     invoke them later ("Claude, use the chart-dashboard-plan skill"), but
 *     they're still gitignored — committing them would balloon every PR
 *     diff and surprise users who run `git add .` after the wizard.
 */
exports.WIZARD_GITIGNORE_PATTERNS = [
    '.amplitude/dashboard.json',
    '.amplitude-events.json',
    '.amplitude-dashboard.json',
    // Note: amplitude-setup-report.md (the CURRENT report) is intentionally
    // NOT gitignored — many users want to commit it as part of their
    // analytics docs. Only the wizard-managed archive of the PRIOR report
    // is hidden from source control. (PR 316.)
    exports.PREVIOUS_SETUP_REPORT_FILENAME,
    '.claude/skills/integration-*/',
    '.claude/skills/add-analytics-instrumentation/',
    '.claude/skills/amplitude-chart-dashboard-plan/',
    '.claude/skills/amplitude-quickstart-taxonomy-agent/',
    '.claude/skills/wizard-prompt-supplement/',
];
/** Marker comment identifying the block as wizard-managed. */
const WIZARD_GITIGNORE_HEADER = '# Amplitude wizard';
/**
 * Append the wizard's artifact patterns to `<installDir>/.gitignore`.
 * Idempotent: re-running after the patterns are already present is a no-op.
 * Creates the file if it doesn't exist.
 *
 * Patterns are written in a single contiguous block under a marker comment
 * so future edits / removals can target the block without disturbing the
 * user's other gitignore content. Silent on I/O errors so a gitignore
 * write failure never blocks the wizard run.
 */
function ensureWizardArtifactsIgnored(installDir) {
    const gitignorePath = path_1.default.join(installDir, '.gitignore');
    try {
        let existing = '';
        if (fs_1.default.existsSync(gitignorePath)) {
            existing = fs_1.default.readFileSync(gitignorePath, 'utf8');
        }
        // Already covered? Match by checking every pattern is present as a
        // standalone line. Cheap to re-check on every run.
        const lines = existing.split('\n').map((l) => l.trim());
        const missing = exports.WIZARD_GITIGNORE_PATTERNS.filter((p) => !lines.includes(p));
        if (missing.length === 0)
            return;
        const block = [WIZARD_GITIGNORE_HEADER, ...exports.WIZARD_GITIGNORE_PATTERNS].join('\n');
        // If the marker is already present, replace the block in place rather
        // than appending a second one. This handles the case where someone added
        // a new pattern to WIZARD_GITIGNORE_PATTERNS in a later wizard version.
        //
        // The trailing `(?:\n[^\n]+)*` matches consecutive non-empty lines after
        // the marker — `[^\n]+` (one or more) instead of `[^\n]*` (zero or more)
        // is critical: the latter matches empty content between two `\n`s and
        // greedily consumes blank lines, which would silently swallow any user
        // content below the wizard block.
        if (existing.includes(WIZARD_GITIGNORE_HEADER)) {
            const updated = existing.replace(new RegExp(`${escapeRegex(WIZARD_GITIGNORE_HEADER)}(?:\\n[^\\n]+)*`, 'm'), block);
            fs_1.default.writeFileSync(gitignorePath, updated, 'utf8');
            return;
        }
        // No marker yet — append a fresh block, separated by a blank line if
        // the file is non-empty.
        const separator = existing.length === 0 || existing.endsWith('\n\n')
            ? ''
            : existing.endsWith('\n')
                ? '\n'
                : '\n\n';
        fs_1.default.writeFileSync(gitignorePath, `${existing}${separator}${block}\n`, 'utf8');
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`ensureWizardArtifactsIgnored: ${msg}`);
    }
}
function escapeRegex(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
 * Archive `<installDir>/amplitude-setup-report.md` to a single sibling
 * `amplitude-setup-report.previous.md` if present.
 *
 * Called at the START of a wizard run so the outro screen never advertises
 * a stale report from a previous run (e.g. against a different workspace,
 * or before the user re-authenticated) as if it described THIS run. The
 * fresh report still lands at the canonical `amplitude-setup-report.md`
 * path so existing tooling, CI, and gitignore rules don't need to change.
 *
 * UX choice: we keep exactly ONE prior report, not a timestamped history.
 * Most users want "the latest report" and at most "the one before that"
 * for comparison after a workspace switch. A growing pile of timestamped
 * files (`amplitude-setup-report.2026-04-27T09-16-23.md`, ...) clutters
 * the project root after a handful of runs and adds zero value over the
 * previous-only approach for the typical workflow. If anyone needs a
 * deeper audit trail, git history or CI logs are the right place — not
 * the project root.
 *
 * Silent on I/O errors so a failed archive never blocks the wizard run.
 */
function archiveSetupReportFile(installDir) {
    const target = path_1.default.join(installDir, 'amplitude-setup-report.md');
    const archivePath = path_1.default.join(installDir, exports.PREVIOUS_SETUP_REPORT_FILENAME);
    try {
        if (!fs_1.default.existsSync(target))
            return;
        // rename atomically replaces an existing destination on POSIX, so a
        // run-N+2 cleanly overwrites whatever previous.md held from run-N+1.
        // Each run preserves the immediately-prior report; older ones roll off.
        fs_1.default.renameSync(target, archivePath);
        (0, debug_1.logToFile)(`archiveSetupReportFile: ${target} → ${archivePath}`);
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`archiveSetupReportFile: ${msg}`);
    }
}
/**
 * Inverse of {@link archiveSetupReportFile}. Restores
 * `amplitude-setup-report.previous.md` back to `amplitude-setup-report.md`
 * if and only if the canonical path is currently absent.
 *
 * This protects against data loss on cancel / error paths: the wizard
 * archives the prior report at run start, but if the run never reaches
 * the conclude phase (Ctrl+C, agent crash, network error, etc.) nothing
 * writes a fresh canonical report. Without this restore, the user is
 * left with NO report at the canonical path — only the archive — which
 * is functionally the same as deleting their previous report.
 *
 * Silent on I/O errors so a failed restore never blocks teardown.
 */
function restoreSetupReportIfMissing(installDir) {
    const target = path_1.default.join(installDir, 'amplitude-setup-report.md');
    const archivePath = path_1.default.join(installDir, exports.PREVIOUS_SETUP_REPORT_FILENAME);
    try {
        // Only restore when the canonical slot is empty — never overwrite a
        // fresh report that the agent did manage to write before failure.
        if (fs_1.default.existsSync(target))
            return;
        if (!fs_1.default.existsSync(archivePath))
            return;
        fs_1.default.renameSync(archivePath, target);
        (0, debug_1.logToFile)(`restoreSetupReportIfMissing: ${archivePath} → ${target}`);
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`restoreSetupReportIfMissing: ${msg}`);
    }
}
/**
 * Run wizard-artifact cleanup.
 *
 * Currently composes a single step:
 *   - cleanupIntegrationSkills (remove `.claude/skills/integration-*`,
 *     onSuccess only — preserves them on cancel/error so a re-run
 *     doesn't re-download the skill)
 *
 * Notes on what's intentionally PRESERVED on every exit (success, cancel,
 * error):
 *   - `<installDir>/.amplitude/events.json`, `project-binding.json`, and
 *     `dashboard.json` — canonical project metadata. `events.json` is the
 *     authoritative record of the user's confirmed event plan and is reused
 *     across runs for re-instrumentation. `dashboard.json` is gitignored via
 *     `.amplitude/dashboard.json` so machine-local URLs do not pollute commits;
 *     other `.amplitude/*` files may be committed when teams want.
 *   - Legacy `<installDir>/.amplitude-events.json` and
 *     `.amplitude-dashboard.json` when they already exist — the wizard no
 *     longer writes these paths but does not delete them; readers prefer
 *     `.amplitude/` and fall back to legacy for migration.
 *   - `<installDir>/amplitude-setup-report.md` — user-facing summary the
 *     OutroScreen points at. Kept on disk so the user can read it after
 *     exit; the next run overwrites it.
 *   - Instrumentation and taxonomy skills (everything in
 *     `.claude/skills/` that isn't `integration-*`) — users invoke
 *     these later for event discovery and dashboard planning.
 *     `ensureWizardArtifactsIgnored` keeps them out of git.
 *
 * Silent on errors. Safe to call from any exit path; idempotent.
 */
function cleanupWizardArtifacts(installDir, options = {}) {
    // Integration skills are single-use SDK-setup workflows — only delete
    // them after a successful run, so the user can re-run cleanly without
    // re-downloading the skill on cancel/error.
    if (options.onSuccess) {
        cleanupIntegrationSkills(installDir);
    }
}
/**
 * Remove wizard-installed integration skills from `<installDir>/.claude/skills/`.
 *
 * Integration skills are a single-use SDK-setup workflow — they're dead
 * weight once the run completes. Instrumentation and taxonomy skills stay
 * on disk because users can invoke them later for event discovery, chart
 * building, and dashboard planning.
 *
 * Only directories whose name starts with `integration-` are removed; other
 * content under `.claude/skills/` (user-owned skills, instrumentation-*,
 * taxonomy-*) is left alone. Silent on I/O errors so a cleanup failure
 * never blocks the success path.
 */
function cleanupIntegrationSkills(installDir) {
    const skillsDir = path_1.default.join(installDir, '.claude', 'skills');
    if (!fs_1.default.existsSync(skillsDir))
        return;
    try {
        for (const name of fs_1.default.readdirSync(skillsDir)) {
            if (!name.startsWith('integration-'))
                continue;
            const target = path_1.default.join(skillsDir, name);
            try {
                fs_1.default.rmSync(target, { recursive: true, force: true });
                (0, debug_1.logToFile)(`cleanupIntegrationSkills: removed ${target}`);
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                (0, debug_1.logToFile)(`cleanupIntegrationSkills: failed to remove ${target}: ${msg}`);
            }
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`cleanupIntegrationSkills: error scanning ${skillsDir}: ${msg}`);
    }
}
let _sdkModule = null;
async function getSDKModule() {
    if (!_sdkModule) {
        _sdkModule = (await import('@anthropic-ai/claude-agent-sdk'));
    }
    return _sdkModule;
}
// ---------------------------------------------------------------------------
// Env file helpers
// ---------------------------------------------------------------------------
/**
 * Resolve filePath relative to workingDirectory, rejecting path traversal.
 */
function resolveEnvPath(workingDirectory, filePath) {
    const resolved = path_1.default.resolve(workingDirectory, filePath);
    if (!resolved.startsWith(workingDirectory + path_1.default.sep) &&
        resolved !== workingDirectory) {
        throw new Error(`Path traversal rejected: "${filePath}" resolves outside working directory`);
    }
    return resolved;
}
/**
 * Env file basenames that are often **committed** as shared defaults (Vite,
 * CRA, monorepos). Auto-appending them to `.gitignore` after `set_env_values`
 * breaks those workflows and encourages putting API keys in tracked files.
 * Browser Amplitude keys should live in `*.local` siblings instead.
 */
exports.SHARED_COMMITTED_ENV_BASENAMES = new Set([
    '.env',
    '.env.development',
    '.env.production',
    '.env.test',
    '.env.staging',
    '.env.defaults',
    '.env.example',
]);
function shouldSkipAutoGitignoreForEnvBasename(envBasename) {
    return exports.SHARED_COMMITTED_ENV_BASENAMES.has(envBasename);
}
/**
 * Ensure the given env file basename is covered by .gitignore in the working directory.
 * Creates .gitignore if it doesn't exist; appends the entry if missing.
 *
 * Skips shared template names (see {@link SHARED_COMMITTED_ENV_BASENAMES}) so
 * we never gitignore files many repos intentionally track.
 */
function ensureGitignoreCoverage(workingDirectory, envFileName) {
    if (shouldSkipAutoGitignoreForEnvBasename(envFileName)) {
        return;
    }
    const gitignorePath = path_1.default.join(workingDirectory, '.gitignore');
    if (fs_1.default.existsSync(gitignorePath)) {
        const content = fs_1.default.readFileSync(gitignorePath, 'utf8');
        // Check if the file (or a glob covering it) is already listed
        if (content.split('\n').some((line) => line.trim() === envFileName)) {
            return;
        }
        const newContent = content.endsWith('\n')
            ? `${content}${envFileName}\n`
            : `${content}\n${envFileName}\n`;
        fs_1.default.writeFileSync(gitignorePath, newContent, 'utf8');
    }
    else {
        fs_1.default.writeFileSync(gitignorePath, `${envFileName}\n`, 'utf8');
    }
}
/**
 * Parse a .env file's content and return the set of defined key names.
 */
function parseEnvKeys(content) {
    const keys = new Set();
    for (const line of content.split('\n')) {
        const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
        if (match) {
            keys.add(match[1]);
        }
    }
    return keys;
}
/**
 * Merge key-value pairs into existing .env content.
 * Updates existing keys in-place, appends new keys at the end.
 */
function mergeEnvValues(content, values) {
    let result = content;
    const updatedKeys = new Set();
    for (const [key, value] of Object.entries(values)) {
        const regex = new RegExp(`^(\\s*${key}\\s*=).*$`, 'm');
        if (regex.test(result)) {
            result = result.replace(regex, `$1${value}`);
            updatedKeys.add(key);
        }
    }
    const newKeys = Object.entries(values).filter(([key]) => !updatedKeys.has(key));
    if (newKeys.length > 0) {
        if (result.length > 0 && !result.endsWith('\n')) {
            result += '\n';
        }
        for (const [key, value] of newKeys) {
            result += `${key}=${value}\n`;
        }
    }
    return result;
}
// ---------------------------------------------------------------------------
// Event-name normalization
// ---------------------------------------------------------------------------
/**
 * Normalize an event name to the canonical Title Case shape mandated by
 * the wizard commandments ("[Noun] [Past-Tense Verb]", 2–5 words, ≤50
 * chars).
 *
 * The system prompt asks the model for Title Case, but the
 * `confirm_event_plan` tool schema historically said "lowercase". When
 * the agent saw both, it sometimes emitted snake_case or all-lowercase
 * names, which then rendered as ugly bullets in the Event Plan viewer
 * and broke chart legends downstream. Rather than reject and force a
 * second prompt round-trip, normalize forgivingly here so the contract
 * is always met regardless of which guidance the model believed.
 *
 * Soft normalization — never throws, never rejects. Returns the name
 * unchanged if it's already correctly shaped; converts snake_case,
 * kebab-case, camelCase, and ALL-LOWERCASE inputs into Title Case.
 *
 * Exported for unit testing.
 */
function normalizeEventName(raw) {
    const trimmed = raw.trim();
    if (!trimmed)
        return trimmed;
    // Convert separators (underscore, hyphen, dot) to spaces.
    let working = trimmed.replace(/[_\-.]+/g, ' ');
    // Split camelCase / PascalCase boundaries: insert a space before any
    // uppercase letter that follows a lowercase letter or digit. ASCII-only
    // — event names are English by convention; non-ASCII is left alone so
    // we don't munge intentional UTF-8 in pathological cases.
    working = working.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
    // Collapse runs of whitespace.
    working = working.replace(/\s+/g, ' ').trim();
    if (!working)
        return trimmed;
    // Title-case each word. Preserve fully-uppercase tokens of length ≤4
    // (acronyms like "API", "URL", "SDK"); otherwise capitalize first
    // letter and lowercase the rest.
    const titled = working
        .split(' ')
        .map((word) => {
        if (!word)
            return word;
        if (word.length <= 4 && /^[A-Z]+$/.test(word))
            return word;
        return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
    })
        .join(' ');
    // Cap at 50 chars to match the wizard's truncation rule.
    return titled.length > 50 ? titled.slice(0, 45) + '…' : titled;
}
// ---------------------------------------------------------------------------
// Event plan persistence
// ---------------------------------------------------------------------------
/**
 * Write the canonical event plan to `<workingDirectory>/.amplitude/events.json`
 * using the shape the wizard UI expects: `[{name, description}]`.
 *
 * Does not write the legacy root `.amplitude-events.json` — readers use
 * {@link readLocalEventPlan} which prefers the canonical file and still
 * honors a pre-existing legacy dotfile from older runs.
 *
 * The agent is instructed (via commandments + integration skills) not to
 * write the canonical file itself — the wizard tool is the single writer so
 * the shape cannot drift. Exported for testing.
 *
 * The `.amplitude/` directory is created lazily on first write and is
 * gitignored as a single line.
 *
 * Returns true on success, false on any filesystem error (the caller logs
 * but doesn't fail the tool call over persistence issues).
 */
function persistEventPlan(workingDirectory, events) {
    try {
        // Refuse to materialize an event plan in a directory the wizard wasn't
        // pointed at. Without this guard, `getProjectMetaDir` + recursive mkdir
        // would happily synthesize the parents — turning a typo or missing
        // installDir into silent file creation in unexpected places.
        if (!fs_1.default.existsSync(workingDirectory)) {
            (0, debug_1.logToFile)(`persistEventPlan: working directory does not exist: ${workingDirectory}`);
            return false;
        }
        (0, storage_paths_1.ensureDir)((0, storage_paths_1.getProjectMetaDir)(workingDirectory), 0o755);
        (0, atomic_write_1.atomicWriteJSON)((0, storage_paths_1.getEventsFile)(workingDirectory), events);
        return true;
    }
    catch (err) {
        (0, debug_1.logToFile)(`persistEventPlan: ${err instanceof Error ? err.message : String(err)}`);
        return false;
    }
}
/**
 * Write the dashboard payload to the canonical
 * `<workingDirectory>/.amplitude/dashboard.json`.
 *
 * Called from the dashboard file-watcher in `agent-interface.ts` whenever a
 * valid dashboard file is detected. Idempotent and silent on errors.
 */
function persistDashboard(workingDirectory, content) {
    try {
        if (!fs_1.default.existsSync(workingDirectory)) {
            (0, debug_1.logToFile)(`persistDashboard: working directory does not exist: ${workingDirectory}`);
            return false;
        }
        (0, storage_paths_1.ensureDir)((0, storage_paths_1.getProjectMetaDir)(workingDirectory), 0o755);
        (0, atomic_write_1.atomicWriteJSON)((0, storage_paths_1.getDashboardFile)(workingDirectory), content);
        return true;
    }
    catch (err) {
        (0, debug_1.logToFile)(`persistDashboard: ${err instanceof Error ? err.message : String(err)}`);
        return false;
    }
}
/**
 * Read the persisted event plan (canonical `.amplitude/events.json`, then
 * legacy `.amplitude-events.json` by mtime). Returns an empty array on any
 * failure — the fallback writer is best-effort.
 */
function readPersistedEventPlan(installDir) {
    return (0, event_plan_parser_js_1.readLocalEventPlan)(installDir);
}
/**
 * Render a minimal Markdown setup report from session state. Exported so
 * tests can lock down the formatting without going through the filesystem.
 */
function buildFallbackReport(ctx) {
    const events = readPersistedEventPlan(ctx.installDir);
    const lines = [];
    lines.push('<wizard-report>');
    lines.push('# Amplitude post-wizard report');
    lines.push('');
    lines.push("_This report was generated automatically by the Amplitude wizard. The agent didn't produce one this run, so the wizard wrote a minimal recap from what it knows._");
    lines.push('');
    lines.push('## Integration summary');
    lines.push('');
    const summaryStart = lines.length;
    if (ctx.integration)
        lines.push(`- **Framework**: ${ctx.integration}`);
    if (ctx.workspaceName)
        lines.push(`- **Project**: ${ctx.workspaceName}`);
    if (ctx.envName)
        lines.push(`- **Environment**: ${ctx.envName}`);
    if (lines.length === summaryStart) {
        lines.push('- _Detected framework not available._');
    }
    lines.push('');
    if (events.length > 0) {
        lines.push('## Instrumented events');
        lines.push('');
        lines.push('| Event | Description |');
        lines.push('| --- | --- |');
        for (const e of events) {
            const name = e.name.replace(/\|/g, '\\|');
            const desc = (e.description || '').replace(/\|/g, '\\|');
            lines.push(`| \`${name}\` | ${desc} |`);
        }
        lines.push('');
    }
    else {
        lines.push('## Instrumented events');
        lines.push('');
        lines.push('_No event plan was persisted. If this is unexpected, re-run the wizard or check `.amplitude/events.json` (or legacy `.amplitude-events.json`) in your project._');
        lines.push('');
    }
    lines.push('## Analytics dashboard');
    lines.push('');
    if (ctx.dashboardUrl) {
        lines.push(`Open your dashboard: ${(0, dashboard_open_url_1.toWizardDashboardOpenUrl)(ctx.dashboardUrl)}`);
    }
    else {
        lines.push("_The wizard didn't capture a dashboard URL. You can build one from your events at https://app.amplitude.com._");
    }
    lines.push('');
    lines.push('## Next steps');
    lines.push('');
    lines.push('- Trigger the instrumented user flows in your app and confirm events appear in Amplitude.');
    lines.push('- Set the Amplitude API key in your production environment (deploy platform settings or CI secrets).');
    lines.push('- Re-run `npx @amplitude/wizard` if you want a richer end-of-run report — the agent writes a more detailed version when it reaches the conclude phase.');
    lines.push('');
    lines.push('</wizard-report>');
    lines.push('');
    return lines.join('\n');
}
/**
 * Write `<installDir>/amplitude-setup-report.md` when the canonical
 * path is empty. Safety net for runs where the agent skipped the
 * conclude-phase report (model variance, ran out of turns, mid-run
 * cancel before conclude, etc.).
 *
 * The companion `archiveSetupReportFile()` (run at the start of every
 * run, in PR #316) moves any prior report to
 * `amplitude-setup-report.previous.md`, so the canonical path is
 * guaranteed to either be empty or freshly written by the current run
 * when this function is called. There's no need for mtime / staleness
 * logic — `existsSync` is authoritative.
 *
 * Returns:
 *   - 'agent-wrote'    — agent wrote a fresh report this run; left untouched.
 *   - 'fallback-wrote' — canonical was missing; wrote a stub.
 *   - 'failed'         — write threw (permissions, disk full, etc.).
 *
 * Silent on errors; never throws — the wizard's outcome must not be
 * blocked by a failed report write.
 */
function writeFallbackReportIfMissing(ctx) {
    const reportPath = path_1.default.join(ctx.installDir, 'amplitude-setup-report.md');
    try {
        if (fs_1.default.existsSync(reportPath)) {
            (0, debug_1.logToFile)(`writeFallbackReportIfMissing: agent already wrote ${reportPath}`);
            return 'agent-wrote';
        }
        const content = buildFallbackReport(ctx);
        fs_1.default.writeFileSync(reportPath, content, 'utf8');
        (0, debug_1.logToFile)(`writeFallbackReportIfMissing: wrote stub report to ${reportPath}`);
        return 'fallback-wrote';
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        (0, debug_1.logToFile)(`writeFallbackReportIfMissing: ${msg}`);
        return 'failed';
    }
}
// ---------------------------------------------------------------------------
// Server factory
// ---------------------------------------------------------------------------
const SERVER_NAME = 'wizard-tools';
/**
 * Description appended to every tool's `reason` parameter. Kept as a constant
 * so all wizard-tools schemas describe `reason` identically — the agent reads
 * the description verbatim to decide what to write here.
 */
const REASON_FIELD_DESCRIPTION = 'A short sentence (≤25 words) explaining WHY you are invoking this tool right now — what you are trying to accomplish at this step. Captured in Agent Analytics so the team can understand intent across runs.';
/**
 * Reusable Zod field for the `reason` parameter required on every wizard tool.
 * Adding `.min(1)` so an empty string is rejected — analytics needs real text.
 */
const reasonField = zod_1.z.string().min(1).describe(REASON_FIELD_DESCRIPTION);
/**
 * Create the unified in-process MCP server with all wizard tools.
 * Must be called asynchronously because the SDK is an ESM module loaded via dynamic import.
 */
async function createWizardToolsServer(options) {
    const { workingDirectory, detectPackageManager, skillsBaseUrl, statusReporter, } = options;
    const { tool, createSdkMcpServer } = await getSDKModule();
    // Skill menu loading (cachedSkillMenu / categoryNames) intentionally
    // skipped — the load_skill_menu / install_skill tools that consumed
    // it are disabled (see the disabled-tool block further down). When
    // those tools are re-enabled, restore the menu loading too:
    //
    //   const menu = skillsBaseUrl
    //     ? (await fetchSkillMenu(skillsBaseUrl)) ?? loadBundledSkillMenu()
    //     : loadBundledSkillMenu();
    //   const cachedSkillMenu: Record<string, SkillEntry[]> = menu?.categories ?? {};
    //   const keys = Object.keys(cachedSkillMenu);
    //   const categoryNames: [string, ...string[]] =
    //     keys.length > 0 ? (keys as [string, ...string[]]) : ['integration'];
    //
    // `skillsBaseUrl` is still threaded through this factory's signature
    // so re-enabling doesn't require changing the public API.
    void skillsBaseUrl;
    // -- check_env_keys -------------------------------------------------------
    const checkEnvKeys = tool('check_env_keys', 'Check which environment variable keys are present or missing in a .env file. Never reveals values.', {
        filePath: zod_1.z
            .string()
            .describe('Path to the .env file, relative to the project root'),
        keys: zod_1.z
            .array(zod_1.z.string())
            .describe('Environment variable key names to check'),
        reason: reasonField,
    }, (args) => {
        const resolved = resolveEnvPath(workingDirectory, args.filePath);
        (0, debug_1.logToFile)(`check_env_keys: ${resolved}, keys: ${args.keys.join(', ')}`);
        const existingKeys = fs_1.default.existsSync(resolved)
            ? parseEnvKeys(fs_1.default.readFileSync(resolved, 'utf8'))
            : new Set();
        const results = {};
        for (const key of args.keys) {
            results[key] = existingKeys.has(key) ? 'present' : 'missing';
        }
        return {
            content: [
                { type: 'text', text: JSON.stringify(results, null, 2) },
            ],
        };
    });
    // -- set_env_values -------------------------------------------------------
    const setEnvValues = tool('set_env_values', 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage for secret-local files (e.g. .env.local). For Vite and similar stacks, prefer `.env.development.local` / `.env.production.local` when the repo already tracks `.env.development` / `.env.production` — never rely on auto-gitignore for those tracked template names.', {
        filePath: zod_1.z
            .string()
            .describe('Path to the .env file, relative to the project root'),
        values: zod_1.z
            .record(zod_1.z.string(), zod_1.z.string())
            .describe('Key-value pairs to set'),
        reason: reasonField,
    }, (args) => {
        const resolved = resolveEnvPath(workingDirectory, args.filePath);
        (0, debug_1.logToFile)(`set_env_values: ${resolved}, keys: ${Object.keys(args.values).join(', ')}`);
        const existing = fs_1.default.existsSync(resolved)
            ? fs_1.default.readFileSync(resolved, 'utf8')
            : '';
        const content = mergeEnvValues(existing, args.values);
        // Ensure parent directory exists
        const dir = path_1.default.dirname(resolved);
        if (!fs_1.default.existsSync(dir)) {
            fs_1.default.mkdirSync(dir, { recursive: true });
        }
        fs_1.default.writeFileSync(resolved, content, 'utf8');
        // Ensure .gitignore coverage for this env file (skipped for shared
        // committed templates like .env.development — see ensureGitignoreCoverage).
        const envFileName = path_1.default.basename(resolved);
        ensureGitignoreCoverage(workingDirectory, envFileName);
        const skipNote = shouldSkipAutoGitignoreForEnvBasename(envFileName)
            ? ' Note: this filename is often a committed env template; use a `.local` sibling for values that must stay untracked.'
            : '';
        return {
            content: [
                {
                    type: 'text',
                    text: `Updated ${Object.keys(args.values).length} key(s) in ${args.filePath}.${skipNote}`,
                },
            ],
        };
    });
    // -- detect_package_manager -----------------------------------------------
    //
    // The agent typically calls this 2–3 times during a run (before
    // installing the SDK, before running typecheck, sometimes again before
    // verification). Each call previously paid a fresh disk scan of the
    // working directory and any framework-specific lockfile probes —
    // observably 50–250ms in dev, longer on a slow FS. The package manager
    // can't change mid-run, so memoize the first scan for the lifetime of
    // this tools server (one server per wizard run).
    //
    // The cache is a Promise so concurrent calls share the same in-flight
    // scan instead of racing.
    let detectPMCache = null;
    const detectPM = tool('detect_package_manager', 'Detect which package manager(s) the project uses. Returns the name, install command, and run command for each detected package manager. Call this before running any install commands.', {
        reason: reasonField,
    }, async (_args) => {
        if (detectPMCache) {
            (0, debug_1.logToFile)(`detect_package_manager: cache hit for ${workingDirectory}`);
        }
        else {
            (0, debug_1.logToFile)(`detect_package_manager: scanning ${workingDirectory}`);
            detectPMCache = detectPackageManager(workingDirectory).catch((err) => {
                detectPMCache = null;
                throw err;
            });
        }
        const result = await detectPMCache;
        (0, debug_1.logToFile)(`detect_package_manager: detected ${result.detected.length} package manager(s)`);
        return {
            content: [
                {
                    type: 'text',
                    text: JSON.stringify(result, null, 2),
                },
            ],
        };
    });
    // -- load_skill_menu / install_skill — DISABLED ───────────────────────────
    //
    // Both tools currently 400 in production: remote skill downloads return
    // not-found errors and the bundled-skill fallback hits packs the agent
    // can't make sense of without the runtime menu. Until the catalogue
    // / download path is fixed, don't expose them to the agent — they
    // confuse it more than they help (the agent loops calling
    // load_skill_menu → install_skill → load_skill_menu) and waste turns.
    //
    // Constant skills (taxonomy + instrumentation + dashboard) are still
    // pre-installed at runtime by `installConstantSkills`, so the agent
    // can `Skill.load` them directly without going through this menu.
    //
    // To re-enable: uncomment the two `tool(...)` blocks below, add them
    // back to the `tools: [...]` array on `createSdkMcpServer`, and add
    // their names back to `WIZARD_TOOL_NAMES`. The original implementations
    // are preserved here so re-enabling is a small diff.
    /*
    const loadSkillMenu = tool(
      'load_skill_menu',
      'Load available Amplitude skills for a category. Returns skill IDs and names. Call this first, then use install_skill with the chosen ID.',
      {
        category: z.enum(categoryNames).describe('Skill category'),
        reason: reasonField,
      },
      (args: { category: string; reason: string }) => {
        const skills = cachedSkillMenu[args.category];
        if (!skills || skills.length === 0) {
          return {
            content: [
              {
                type: 'text' as const,
                text: `No skills found for category "${args.category}".`,
              },
            ],
            isError: true,
          };
        }
  
        const menuText = skills.map((s) => `- ${s.id}: ${s.name}`).join('\n');
  
        logToFile(
          `load_skill_menu: returning ${skills.length} skills for "${args.category}"`,
        );
  
        return {
          content: [{ type: 'text' as const, text: menuText }],
        };
      },
    );
  
    const installSkill = tool(
      'install_skill',
      'Download and install an Amplitude skill by ID. Call load_skill_menu first to see available skills. Extracts the skill to .claude/skills/<skillId>/.',
      {
        skillId: z
          .string()
          .describe(
            'Skill ID from the skill menu (e.g., "integration-nextjs-app-router")',
          ),
        reason: reasonField,
      },
      (args: { skillId: string; reason: string }) => {
        if (!/^[a-z0-9][a-z0-9_-]*$/.test(args.skillId)) {
          return {
            content: [
              {
                type: 'text' as const,
                text: 'Error: skillId must be lowercase alphanumeric with hyphens.',
              },
            ],
            isError: true,
          };
        }
  
        const allSkills: SkillEntry[] = Object.values(cachedSkillMenu).flat();
        const skill = allSkills.find((s) => s.id === args.skillId);
        if (!skill) {
          return {
            content: [
              {
                type: 'text' as const,
                text: `Error: skill "${args.skillId}" not found. Use load_skill_menu to see available skills.`,
              },
            ],
            isError: true,
          };
        }
  
        const result = skill.downloadUrl
          ? downloadSkill(skill, workingDirectory)
          : installBundledSkill(args.skillId, workingDirectory);
        if (result.success) {
          return {
            content: [
              {
                type: 'text' as const,
                text: `Skill installed to .claude/skills/${args.skillId}/`,
              },
            ],
          };
        } else {
          return {
            content: [
              {
                type: 'text' as const,
                text: `Error installing skill: ${result.error}`,
              },
            ],
            isError: true,
          };
        }
      },
    );
    */
    // -- confirm --------------------------------------------------------------
    const confirm = tool('confirm', 'Ask the user a yes/no question and wait for their answer. Returns true if confirmed, false if declined or skipped.', {
        message: zod_1.z
            .string()
            .describe('The confirmation question to show the user'),
        reason: reasonField,
    }, async (args) => {
        (0, debug_1.logToFile)(`confirm: ${args.message}`);
        const answer = await (0, ui_1.getUI)().promptConfirm(args.message);
        return {
            content: [{ type: 'text', text: answer ? 'true' : 'false' }],
        };
    });
    // -- choose ---------------------------------------------------------------
    const choose = tool('choose', 'Present the user with a list of options and wait for their selection. Returns the chosen option, or an empty string if skipped.', {
        message: zod_1.z.string().describe('The prompt to show above the options'),
        options: zod_1.z
            .array(zod_1.z.string())
            .min(2)
            .describe('The list of choices to present'),
        reason: reasonField,
    }, async (args) => {
        (0, debug_1.logToFile)(`choose: ${args.message}, options: ${args.options.join(', ')}`);
        const answer = await (0, ui_1.getUI)().promptChoice(args.message, args.options);
        return {
            content: [{ type: 'text', text: answer }],
        };
    });
    // -- confirm_event_plan ---------------------------------------------------
    const confirmEventPlan = tool('confirm_event_plan', `Present the proposed instrumentation plan to the user for review BEFORE instrumenting any events.
Call this tool AFTER installing the SDK and adding initialization code, but BEFORE writing any track() calls.
The user can approve the plan, skip the review, or give feedback.
If the user gives feedback, revise your plan and call this tool again — loop until approved or skipped.
Returns: "approved", "skipped", or "feedback: <user message>"`, {
        events: zod_1.z
            .array(zod_1.z.object({
            name: zod_1.z
                .string()
                .describe('Title Case event name, [Noun] [Past-Tense Verb], 2-5 words. Examples: "User Signed Up", "Product Added To Cart", "Search Performed", "Checkout Started". NOT snake_case ("user_signed_up"), camelCase ("userSignedUp"), or lowercase ("user signed up"). Do NOT put descriptions or file paths here.'),
            description: zod_1.z
                .string()
                .describe('One short sentence (≤20 words) stating when this event fires. Do NOT include file paths, property lists, autocapture rationale, or implementation notes — those belong in internal planning, not in the user-facing plan.'),
        }))
            .min(1)
            .describe('The list of events you plan to instrument'),
        reason: reasonField,
    }, async (args) => {
        const { DEMO_MODE } = await import('./constants.js');
        // Soft-gate the name format. Agents historically saw conflicting
        // guidance (commandments said Title Case, the tool schema said
        // lowercase) and emitted mixed shapes. Normalize forgivingly here
        // so the persisted plan AND the user-facing prompt always match
        // the canonical Title Case shape — no second prompt round-trip
        // needed when the model gets it slightly wrong.
        let normalizationCount = 0;
        const normalizedEvents = args.events.map((e) => {
            const original = e.name.trim();
            const normalized = normalizeEventName(original);
            if (normalized !== original)
                normalizationCount += 1;
            return {
                name: normalized,
                description: e.description?.trim() || '',
            };
        });
        if (normalizationCount > 0) {
            (0, debug_1.logToFile)(`confirm_event_plan: normalized ${normalizationCount}/${args.events.length} event name(s) to Title Case`);
        }
        const events = DEMO_MODE && normalizedEvents.length > 5
            ? normalizedEvents.slice(0, 5)
            : normalizedEvents;
        (0, debug_1.logToFile)(`confirm_event_plan: ${events.length} events${DEMO_MODE ? ' (demo mode)' : ''}`);
        const decision = await (0, ui_1.getUI)().promptEventPlan(events);
        let text;
        if (decision.decision === 'revised') {
            text = `feedback: ${decision.feedback}`;
        }
        else {
            text = decision.decision; // 'approved' or 'skipped'
        }
        // Persist the canonical event plan to `.amplitude/events.json` so the
        // watcher and return-run loader see the same {name, description} shape
        // regardless of what the agent would otherwise emit. Only write on
        // approved to avoid overwriting a prior good plan with a rejected one.
        if (decision.decision === 'approved') {
            const persisted = persistEventPlan(workingDirectory, events);
            (0, debug_1.logToFile)(`confirm_event_plan: persist=${persisted} events=${events.length}`);
        }
        (0, debug_1.logToFile)(`confirm_event_plan result: ${text}`);
        return { content: [{ type: 'text', text }] };
    });
    // -- report_status --------------------------------------------------------
    // Rate-limit per code: reject if the agent calls report_status for the same
    // (kind, code) more than `RATE_LIMIT_MAX` times inside RATE_LIMIT_WINDOW_MS.
    // Prevents the model from spamming status with duplicate reports.
    const RATE_LIMIT_WINDOW_MS = 1000;
    const RATE_LIMIT_MAX = 5;
    const reportHistory = new Map();
    const reportStatus = tool('report_status', 'Report a structured status update (kind: "status") or a fatal error (kind: "error") to the wizard. Use instead of emitting [STATUS] or [ERROR-*] text markers in your output. The wizard routes status updates to the spinner and errors to the outro screen.', {
        kind: zod_1.z
            .enum(['status', 'error'])
            .describe('"status" for in-progress progress updates shown in the spinner; "error" to signal a fatal condition the wizard should surface on the outro.'),
        code: zod_1.z
            .string()
            .min(1)
            .max(64)
            .describe('Machine-readable code. Errors: MCP_MISSING, RESOURCE_MISSING, API_ERROR, RATE_LIMIT, AUTH_ERROR. Status: short kebab-case identifier like "skill-loaded".'),
        detail: zod_1.z
            .string()
            .min(1)
            .max(500)
            .describe('Short human-readable message, shown verbatim to the user.'),
        reason: reasonField,
    }, (args) => {
        const now = Date.now();
        const key = `${args.kind}:${args.code}`;
        const history = reportHistory.get(key) ?? [];
        // Drop events outside the rate window.
        const fresh = history.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
        if (fresh.length >= RATE_LIMIT_MAX) {
            (0, debug_1.logToFile)(`report_status rate-limited: ${key} (${fresh.length} calls in ${RATE_LIMIT_WINDOW_MS}ms)`);
            return {
                content: [
                    {
                        type: 'text',
                        text: `rate_limited: pause before reporting ${key} again`,
                    },
                ],
            };
        }
        fresh.push(now);
        reportHistory.set(key, fresh);
        const report = {
            kind: args.kind,
            code: args.code,
            detail: args.detail,
        };
        (0, debug_1.logToFile)(`report_status: ${args.kind}/${args.code} — ${args.detail}`);
        const reporter = statusReporter?.();
        if (reporter) {
            if (args.kind === 'error') {
                reporter.onError(report);
            }
            else {
                reporter.onStatus(report);
            }
        }
        return {
            content: [{ type: 'text', text: 'ok' }],
        };
    });
    // -- record_dashboard -----------------------------------------------------
    // Persists a dashboard the agent just created via the Amplitude MCP. This
    // is the explicit hand-off from the in-loop agent (which does the actual
    // chart/dashboard MCP calls during the "Build your starter dashboard" task) to the
    // wizard's outro and post-agent step. Writes
    // `<installDir>/.amplitude/dashboard.json` (via persistDashboard).
    //
    // When this tool fires, `createDashboardStep` finds the file on its next
    // pass and short-circuits to its reuse path — no 90s MCP+sub-agent fallback,
    // no "Creating charts and dashboard in Amplitude…" spinner hang.
    const recordDashboard = tool('record_dashboard', `Record an Amplitude dashboard you just created via the Amplitude MCP server.
Call this tool IMMEDIATELY after \`create_dashboard\` returns successfully — it persists the URL and chart metadata so the wizard's outro can link to it and the post-agent fallback step can short-circuit.
Required: dashboardUrl. Optional but recommended: dashboardId, charts (id/title/type per chart).
Returns: "ok" on successful persistence, an error string otherwise. Idempotent — safe to call again with updated values.`, {
        dashboardUrl: zod_1.z
            .string()
            .url()
            .describe('The full HTTPS URL to the created dashboard in Amplitude (e.g. https://app.amplitude.com/.../dashboard/abc123). Must be a valid URL — the outro renders this as a clickable link.'),
        dashboardId: zod_1.z
            .string()
            .min(1)
            .optional()
            .describe('The dashboard ID returned by the Amplitude MCP create_dashboard call. Optional but recommended for downstream tooling.'),
        charts: zod_1.z
            .array(zod_1.z.object({
            id: zod_1.z.string().optional(),
            title: zod_1.z.string().optional(),
            type: zod_1.z.string().optional(),
        }))
            .optional()
            .describe('Metadata for each chart on the dashboard. Used by the wizard outro and analytics. Pass at least the title and type per chart (e.g. funnel, line, retention).'),
        reason: reasonField,
    }, (args) => {
        const payload = {
            dashboardUrl: args.dashboardUrl,
        };
        if (args.dashboardId)
            payload.dashboardId = args.dashboardId;
        if (args.charts)
            payload.charts = args.charts;
        const persistedCanonical = persistDashboard(workingDirectory, payload);
        (0, debug_1.logToFile)(`record_dashboard: url=${args.dashboardUrl} charts=${args.charts?.length ?? 0} canonical=${persistedCanonical}`);
        // Surface the dashboard URL on the session immediately so the outro
        // (and any soft-error abort path in agent-runner that probes
        // `agentArtifactsLookComplete`) sees the success even if the rest of
        // the agent run trips on a late-stage flush. The post-agent step also
        // sets this, but doing it here makes the in-loop path self-contained.
        try {
            (0, ui_1.getUI)().setDashboardUrl(args.dashboardUrl);
        }
        catch (err) {
            (0, debug_1.logToFile)(`record_dashboard: ui.setDashboardUrl failed: ${err instanceof Error ? err.message : String(err)}`);
        }
        if (!persistedCanonical) {
            return {
                content: [
                    {
                        type: 'text',
                        text: 'error: failed to persist dashboard to disk — see wizard log',
                    },
                ],
            };
        }
        return { content: [{ type: 'text', text: 'ok' }] };
    });
    // -- wizard_feedback ------------------------------------------------------
    // Structured agent-side feedback for blocked or stuck states. Distinct from
    // the user-facing /feedback slash command (`trackWizardFeedback`); this one
    // is invoked by the agent itself when it can't move forward, and emits a
    // queryable event for Agent Analytics so we can find broken flows without
    // grepping logs. Only used for in-run blockers — successful runs should not
    // call this.
    const wizardFeedback = tool('wizard_feedback', 'Report a structured blocker or warning when you (the agent) cannot move forward. Use this for unresolvable states, unexpected codebase shapes, missing prerequisites, or persistent tool failures — NOT for routine progress updates (use report_status for those). Surfaces as a queryable signal in Agent Analytics so the team can see where runs get stuck.', {
        goal: zod_1.z
            .string()
            .min(1)
            .max(500)
            .describe('What you were trying to accomplish at this step.'),
        steps_tried: zod_1.z
            .array(zod_1.z.string().min(1).max(500))
            .min(1)
            .describe('The concrete steps or tool calls you attempted before reporting this blocker.'),
        blocker: zod_1.z
            .string()
            .min(1)
            .max(1000)
            .describe('What is preventing you from continuing — error message, missing file, ambiguous codebase shape, etc.'),
        severity: zod_1.z
            .enum(['warn', 'error'])
            .describe('"warn" if you can continue with a degraded result; "error" if the run cannot proceed.'),
        reason: reasonField,
    }, (args) => {
        (0, debug_1.logToFile)(`wizard_feedback (${args.severity}): goal="${args.goal}" blocker="${args.blocker}"`);
        // Lazy-import to avoid a static dependency cycle: utils/analytics
        // imports from lib/* indirectly through other shared modules, and we
        // want this MCP server to remain importable from anywhere without
        // pulling the analytics client into module init.
        void (async () => {
            try {
                const { analytics } = await import('../utils/analytics.js');
                analytics.wizardCapture('agent feedback submitted', {
                    goal: args.goal,
                    'steps tried': args.steps_tried,
                    blocker: args.blocker,
                    severity: args.severity,
                    reason: args.reason,
                });
            }
            catch (err) {
                (0, debug_1.logToFile)(`wizard_feedback: analytics emit failed: ${err instanceof Error ? err.message : String(err)}`);
            }
        })();
        return {
            content: [{ type: 'text', text: 'feedback recorded' }],
        };
    });
    // -- Assemble server ------------------------------------------------------
    const rawServer = createSdkMcpServer({
        name: SERVER_NAME,
        version: '1.0.0',
        tools: [
            checkEnvKeys,
            setEnvValues,
            detectPM,
            // loadSkillMenu and installSkill intentionally not exposed — see
            // the disabled-tool block above for context. Constant skills are
            // pre-installed at runtime so the agent can `Skill.load` them
            // directly.
            confirm,
            choose,
            confirmEventPlan,
            reportStatus,
            recordDashboard,
            wizardFeedback,
        ],
    });
    // Wrap with Sentry auto-instrumentation so every wizard-tools call gets a
    // span in the active trace. No-op when telemetry is disabled — returns
    // the raw server unchanged. The agent SDK types `createSdkMcpServer` as
    // returning `unknown`, so we narrow to `object` here for the wrapper.
    return (0, index_1.wrapMcpServerWithSentry)(rawServer);
}
/** Tool names exposed by the wizard-tools server, for use in allowedTools */
exports.WIZARD_TOOL_NAMES = [
    `${SERVER_NAME}:check_env_keys`,
    `${SERVER_NAME}:set_env_values`,
    `${SERVER_NAME}:detect_package_manager`,
    // load_skill_menu / install_skill intentionally omitted while the
    // skill catalogue / download path is broken — see the disabled-tool
    // block in this file for context.
    `${SERVER_NAME}:confirm`,
    `${SERVER_NAME}:choose`,
    `${SERVER_NAME}:confirm_event_plan`,
    `${SERVER_NAME}:report_status`,
    `${SERVER_NAME}:record_dashboard`,
    `${SERVER_NAME}:wizard_feedback`,
];
/** Stable server name — used by hooks to namespace `mcp__wizard-tools__*` tool calls. */
exports.WIZARD_TOOLS_SERVER_NAME = SERVER_NAME;
