/**
 * `neotoma doctor` — consolidated diagnostics for agent-led onboarding.
 *
 * Returns a single machine-readable snapshot covering install/runtime/API/MCP/
 * CLI instructions/permission-file state so agents do not improvise with shell
 * introspection (python3, grep, ls, cat, jq, find, which, node -e).
 */
import fs from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { discoverApiInstances } from "./config.js";
import { detectHooks, toHookHarness, } from "./hooks_detect.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function isPathWithin(parent, candidate) {
    const relative = path.relative(path.resolve(parent), path.resolve(candidate));
    return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export function suggestSafeDataDir(homeDir = os.homedir()) {
    if (process.platform !== "darwin")
        return null;
    return path.join(homeDir, "Library", "Application Support", "neotoma", "data");
}
export function detectDataDirRisks(params) {
    const dataDir = path.resolve(params.dataDir);
    const homeDir = params.homeDir ?? os.homedir();
    const platform = params.platform ?? process.platform;
    const risks = [];
    const suggestedDir = suggestSafeDataDir(homeDir) ?? path.join(homeDir, "neotoma", "data");
    if (dataDir.includes(`${path.sep}Library${path.sep}Mobile Documents${path.sep}`)) {
        risks.push({
            code: "icloud_drive",
            severity: "warn",
            message: "Data directory is inside iCloud Drive, which can sync SQLite DB/WAL files while Neotoma writes.",
            suggested_action: `Move NEOTOMA_DATA_DIR to a local-only path such as ${suggestedDir}.`,
        });
    }
    if (platform === "darwin") {
        const documentsDir = path.join(homeDir, "Documents");
        const desktopDir = path.join(homeDir, "Desktop");
        if (isPathWithin(documentsDir, dataDir) || isPathWithin(desktopDir, dataDir)) {
            risks.push({
                code: "macos_synced_desktop_or_documents",
                severity: "warn",
                message: "Data directory is under macOS Documents/Desktop, which is commonly iCloud-synced and higher risk for SQLite sidecar files.",
                suggested_action: `Use neotoma storage set-data-dir "${suggestedDir}" --move-db-files after stopping Neotoma.`,
            });
        }
    }
    if (params.hasPriorRepairArtifacts) {
        risks.push({
            code: "prior_sqlite_repair_artifacts",
            severity: "warn",
            message: "Data directory contains prior SQLite repair/swap artifacts, indicating previous corruption or recovery.",
            suggested_action: "Run neotoma storage recover-db for dev and prod after incidents, and keep DBs on local-only storage.",
        });
    }
    return risks;
}
async function hasPriorSqliteRepairArtifacts(dataDir) {
    const directMarkers = ["repair_backups", "repair_swaps", "recover-errors.log"];
    for (const marker of directMarkers) {
        if (existsSync(path.join(dataDir, marker)))
            return true;
    }
    try {
        const entries = await fs.readdir(dataDir);
        return entries.some((entry) => entry.startsWith("db_corrupt_before_swap_"));
    }
    catch {
        return false;
    }
}
function safeExec(cmd, args) {
    try {
        const out = execFileSync(cmd, args, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
        return out.trim() || null;
    }
    catch {
        return null;
    }
}
/** Resolve the neotoma binary and detect which shell-manager owns node (mise/nvm/fnm). */
function detectRuntime() {
    const whichNeotoma = safeExec("which", ["neotoma"]);
    const whichNode = safeExec("which", ["node"]);
    const npmPrefix = safeExec("npm", ["prefix", "-g"]);
    const npmRoot = npmPrefix ? path.join(npmPrefix, "lib", "node_modules") : null;
    const globalBin = npmPrefix ? path.join(npmPrefix, "bin", "neotoma") : null;
    const globalPkgDir = npmRoot ? path.join(npmRoot, "neotoma") : null;
    const globalPkgExists = globalPkgDir ? existsSync(globalPkgDir) : false;
    // Detect resolved_via from the which-neotoma path
    let resolvedVia = "unknown";
    if (whichNeotoma) {
        if (whichNeotoma.includes("/.local/share/mise/") || whichNeotoma.includes("/.mise/")) {
            resolvedVia = "mise";
        }
        else if (whichNeotoma.includes("/.nvm/")) {
            resolvedVia = "nvm";
        }
        else if (whichNeotoma.includes("/.fnm/") || whichNeotoma.includes("/fnm/")) {
            resolvedVia = "fnm";
        }
        else if (whichNeotoma.includes("/node_modules/") ||
            (globalBin && path.resolve(whichNeotoma) === path.resolve(globalBin))) {
            resolvedVia = "npm";
        }
        else {
            resolvedVia = "path";
        }
    }
    // Compute hint when `which neotoma` fails but the global package exists (typical mise/nvm PATH mismatch).
    let pathFixHint = null;
    if (!whichNeotoma && globalPkgExists) {
        if (npmPrefix && npmPrefix.includes("/mise/")) {
            pathFixHint = `Run \`eval "$(mise activate zsh)"\` in this shell (or restart the terminal) so your agent shell finds the \`neotoma\` binary at ${globalBin ?? "the global npm bin"}.`;
        }
        else if (npmPrefix && npmPrefix.includes("/.nvm/")) {
            pathFixHint = `Your Node version manager (nvm) installed neotoma at ${globalBin}. Ensure your non-interactive shell sources nvm (e.g. add \`source "$NVM_DIR/nvm.sh"\` to ~/.zshenv).`;
        }
        else {
            pathFixHint = `\`neotoma\` is not on PATH but exists at ${globalBin ?? globalPkgDir}. Add the containing directory to PATH or re-run \`npm install -g neotoma\` from this shell.`;
        }
    }
    else if (!whichNeotoma && !globalPkgExists) {
        pathFixHint = "Run `npm install -g neotoma` to install the CLI globally.";
    }
    return {
        neotoma_on_path: Boolean(whichNeotoma),
        node_on_path: Boolean(whichNode),
        resolved_via: resolvedVia,
        which_neotoma: whichNeotoma,
        global_bin: globalBin,
        npm_global_root: npmRoot,
        global_package_dir: globalPkgExists ? globalPkgDir : null,
        path_fix_hint: pathFixHint,
    };
}
function readVersionFromPackageJson(pkgPath) {
    try {
        const raw = readFileSync(pkgPath, "utf8");
        const pkg = JSON.parse(raw);
        return pkg.version ?? null;
    }
    catch {
        return null;
    }
}
/** Detect the neotoma version from global package.json or local install. */
function detectVersion(globalPkgDir) {
    if (globalPkgDir) {
        const v = readVersionFromPackageJson(path.join(globalPkgDir, "package.json"));
        if (v)
            return v;
    }
    // Fallback: package.json adjacent to current module (dev checkout)
    try {
        const here = path.resolve(path.join(__dirname, "..", "..", "package.json"));
        const v = readVersionFromPackageJson(here);
        if (v)
            return v;
    }
    catch {
        // ignore
    }
    return null;
}
/** Heuristically detect which agent harness invoked the CLI (for `current_tool_hint`). */
export function detectCurrentToolHint(cwd, env = process.env) {
    if (env.OPENAI_CODEX_BUILD ||
        env.CODEX_HOME ||
        env.CODEX_SHELL ||
        env.CODEX_THREAD_ID ||
        env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE) {
        return "codex";
    }
    if (env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDECODE)
        return "claude-code";
    if (env.CURSOR_AGENT || env.CURSOR_TRACE_ID || env.CURSOR_IDE)
        return "cursor";
    if (env.OPENCLAW_SESSION || env.OPENCLAW_CWD)
        return "openclaw";
    // Second-best: directory-level markers.
    try {
        const markers = [];
        if (existsSync(path.join(cwd, ".claude")))
            markers.push("claude-code");
        if (existsSync(path.join(cwd, ".cursor")))
            markers.push("cursor");
        if (existsSync(path.join(cwd, ".codex")))
            markers.push("codex");
        if (existsSync(path.join(cwd, ".vscode", "mcp.json")))
            markers.push("vscode");
        if (markers.length === 1)
            return markers[0];
    }
    catch {
        // ignore
    }
    return null;
}
/** Check a Claude Code permission file for neotoma wildcard allow entries. */
async function readClaudePermissions(filePath) {
    try {
        const raw = await fs.readFile(filePath, "utf8");
        const parsed = JSON.parse(raw);
        const allow = parsed.permissions?.allow ?? [];
        return {
            path: filePath,
            exists: true,
            has_neotoma_allow: allow.some((s) => /^Bash\(neotoma:\*?\)$/.test(s) || s === "Bash(neotoma:*)"),
            has_npm_install_allow: allow.some((s) => /npm\s+install\s+-g\s+neotoma/.test(s)),
        };
    }
    catch {
        return {
            path: filePath,
            exists: false,
            has_neotoma_allow: false,
            has_npm_install_allow: false,
        };
    }
}
/** Check a Cursor allowlist JSON file (or legacy path) for neotoma entries. */
async function readCursorAllowlist(projectRoot) {
    const candidate = path.join(projectRoot, ".cursor", "allowlist.json");
    try {
        const raw = await fs.readFile(candidate, "utf8");
        const parsed = JSON.parse(raw);
        const allow = parsed.allow ?? [];
        return {
            path: candidate,
            exists: true,
            has_neotoma_allow: allow.some((s) => /neotoma/.test(s)),
            has_npm_install_allow: allow.some((s) => /npm\s+install\s+-g\s+neotoma/.test(s)),
        };
    }
    catch {
        return {
            path: candidate,
            exists: false,
            has_neotoma_allow: false,
            has_npm_install_allow: false,
        };
    }
}
/** Check Codex config.toml for neotoma approval entries. */
async function readCodexApprovals() {
    const home = os.homedir();
    const candidate = path.join(home, ".codex", "config.toml");
    try {
        const raw = await fs.readFile(candidate, "utf8");
        const hasNeotoma = /neotoma/i.test(raw) && /\[approvals?\]/.test(raw);
        const hasNpmAllow = /npm\s+install\s+-g\s+neotoma/.test(raw);
        return {
            path: candidate,
            exists: true,
            has_neotoma_allow: hasNeotoma,
            has_npm_install_allow: hasNpmAllow,
        };
    }
    catch {
        return {
            path: candidate,
            exists: false,
            has_neotoma_allow: false,
            has_npm_install_allow: false,
        };
    }
}
/** Suggest the next step based on consolidated state. */
function suggestNextStep(report) {
    if (!report.neotoma.installed)
        return "install";
    if (!report.data.initialized)
        return "init";
    const mcpConfigured = Object.values(report.mcp_servers_detected).some((c) => c.has_neotoma || c.has_neotoma_dev);
    if (!mcpConfigured)
        return "configure-mcp";
    const cliInstr = report.cli_instructions;
    const hasAnyCliInstr = cliInstr.project.cursor ||
        cliInstr.project.claude ||
        cliInstr.project.codex ||
        cliInstr.user.cursor ||
        cliInstr.user.claude ||
        cliInstr.user.codex;
    if (!hasAnyCliInstr)
        return "configure-cli-instructions";
    const hasPerms = Object.values(report.permission_files).some((p) => p.has_neotoma_allow);
    if (!hasPerms && report.current_tool_hint && report.current_tool_hint !== "openclaw") {
        return "configure-permissions";
    }
    if (!report.api.running)
        return "activate";
    if (report.hooks.eligible_for_offer)
        return "offer-hooks";
    if (report.mirror.eligible_for_offer)
        return "offer-mirror";
    return "ready";
}
/** Build the mirror block for the doctor report. */
async function detectMirror() {
    try {
        const { getMirrorConfig } = await import("../services/canonical_mirror.js");
        const { checkMirrorGitignoreStatus } = await import("./commands/mirror.js");
        const cfg = getMirrorConfig();
        const gitignore = checkMirrorGitignoreStatus(cfg);
        return {
            enabled: cfg.enabled,
            path: path.resolve(cfg.path),
            inside_git_repo: gitignore.inside_git_repo,
            git_repo_root: gitignore.git_repo_root,
            gitignored: gitignore.gitignored,
            eligible_for_offer: !cfg.enabled,
        };
    }
    catch {
        // Never let mirror detection break doctor.
        return {
            enabled: false,
            path: "",
            inside_git_repo: false,
            git_repo_root: null,
            gitignored: false,
            eligible_for_offer: false,
        };
    }
}
export async function runDoctor(opts = {}) {
    const cwd = opts.cwd ?? process.cwd();
    const runtime = detectRuntime();
    const version = detectVersion(runtime.global_package_dir);
    const installed = runtime.neotoma_on_path || runtime.global_package_dir !== null;
    // Data/init status — Fix #170: distinguish env, project-local .env, global default
    const configDir = path.join(os.homedir(), ".config", "neotoma");
    const globalDefaultDataDir = path.join(os.homedir(), "neotoma", "data");
    // Check for a project-local .env in cwd that sets NEOTOMA_DATA_DIR
    let projectLocalDataDir = null;
    try {
        const cwdEnvPath = path.join(cwd, ".env");
        if (existsSync(cwdEnvPath)) {
            const raw = readFileSync(cwdEnvPath, "utf8");
            const match = /^NEOTOMA_DATA_DIR=(.+)$/m.exec(raw);
            if (match?.[1]?.trim()) {
                projectLocalDataDir = match[1].trim().replace(/^["']|["']$/g, "");
            }
        }
    }
    catch {
        // ignore read errors
    }
    let dataDir;
    let dataDirSource;
    const envDataDir = process.env.NEOTOMA_DATA_DIR?.trim();
    if (envDataDir) {
        dataDir = envDataDir;
        // If the env var value matches the project-local .env value, classify as project_local
        dataDirSource =
            projectLocalDataDir && path.resolve(envDataDir) === path.resolve(projectLocalDataDir)
                ? "project_local"
                : "env";
    }
    else if (projectLocalDataDir) {
        dataDir = projectLocalDataDir;
        dataDirSource = "project_local";
    }
    else {
        dataDir = globalDefaultDataDir;
        dataDirSource = "global_default";
    }
    const dbCandidates = [path.join(dataDir, "neotoma.db"), path.join(dataDir, "neotoma.prod.db")];
    const dbExists = dbCandidates.some((p) => existsSync(p));
    const initialized = dbExists && existsSync(configDir);
    const priorRepairArtifacts = await hasPriorSqliteRepairArtifacts(dataDir);
    const risks = detectDataDirRisks({
        dataDir,
        hasPriorRepairArtifacts: priorRepairArtifacts,
    });
    // Detect dev/prod env split (data in neotoma.db when neotoma.prod.db is empty).
    let envSplitWarning = null;
    try {
        const { detectEnvSplitWarning } = await import("./commands/db_migrate_env_split.js");
        const warning = detectEnvSplitWarning(dataDir);
        if (warning.has_split) {
            envSplitWarning = warning;
            risks.push({
                code: "dev_data_in_wrong_env",
                severity: "warn",
                message: `Dev database (neotoma.db) has ${warning.dev_observations} observation(s) and ${warning.dev_sources} source(s) but the production database is empty. Data may have been written to the wrong environment.`,
                suggested_action: `Run: neotoma db migrate-env-split --dry-run`,
            });
        }
    }
    catch {
        // Never let env-split detection break doctor.
    }
    // API running?
    let apiRunning = false;
    let apiEnv = null;
    let apiPort = null;
    const apiPid = null;
    let apiBaseUrl = null;
    try {
        const instances = await discoverApiInstances({ config: opts.config });
        const first = instances.find((i) => i.healthy) ?? instances[0];
        if (first) {
            apiRunning = Boolean(first.healthy);
            apiEnv = first.envHint === "dev" ? "dev" : first.envHint === "prod" ? "prod" : null;
            apiPort = first.port ?? null;
            apiBaseUrl = first.url ?? null;
        }
    }
    catch {
        // ignore discovery errors
    }
    // MCP server detection
    const mcpServersDetected = {};
    try {
        const { scanForMcpConfigs } = await import("./mcp_config_scan.js");
        const { configs } = await scanForMcpConfigs(cwd, { includeUserLevel: true });
        for (const c of configs) {
            const id = inferMcpToolId(c.path);
            mcpServersDetected[id] = {
                path: c.path,
                has_neotoma: c.hasProd,
                has_neotoma_dev: c.hasDev,
            };
        }
    }
    catch {
        // leave empty
    }
    // CLI instructions
    let cliInstructions = {
        project: { cursor: false, claude: false, codex: false },
        user: { cursor: false, claude: false, codex: false },
    };
    try {
        const { scanAgentInstructions } = await import("./agent_instructions_scan.js");
        const res = await scanAgentInstructions(cwd, { includeUserLevel: true });
        cliInstructions = {
            project: {
                cursor: res.appliedProject.cursor,
                claude: res.appliedProject.claude,
                codex: res.appliedProject.codex,
            },
            user: {
                cursor: res.appliedUser.cursor,
                claude: res.appliedUser.claude,
                codex: res.appliedUser.codex,
            },
        };
    }
    catch {
        // leave defaults
    }
    // Permission files
    const permissionFiles = {
        claude_code_project: await readClaudePermissions(path.join(cwd, ".claude", "settings.local.json")),
        claude_code_user: await readClaudePermissions(path.join(os.homedir(), ".claude", "settings.json")),
        cursor_project: await readCursorAllowlist(cwd),
        codex_user: await readCodexApprovals(),
    };
    const currentToolHint = detectCurrentToolHint(cwd);
    // Hooks detection. `toHookHarness` filters out tools without hook support
    // (e.g. claude-desktop, openclaw) so eligibility never triggers on them.
    const mcpConfigured = Object.values(mcpServersDetected).some((c) => c.has_neotoma || c.has_neotoma_dev);
    const currentHookHarness = toHookHarness(currentToolHint);
    const hooks = await detectHooks({
        cwd,
        currentTool: currentHookHarness,
        mcpConfigured,
    });
    const mirror = await detectMirror();
    const partial = {
        neotoma: {
            installed,
            version,
            ...runtime,
        },
        data: {
            config_dir: configDir,
            data_dir: dataDir,
            data_dir_source: dataDirSource,
            global_default_data_dir: dataDirSource !== "global_default" ? globalDefaultDataDir : null,
            db_exists: dbExists,
            initialized,
            risks,
            suggested_safe_data_dir: risks.length > 0 ? suggestSafeDataDir() : null,
            env_split_warning: envSplitWarning,
        },
        api: {
            running: apiRunning,
            env: apiEnv,
            port: apiPort,
            pid: apiPid,
            base_url: apiBaseUrl,
        },
        mcp_servers_detected: mcpServersDetected,
        cli_instructions: cliInstructions,
        permission_files: permissionFiles,
        current_tool_hint: currentToolHint,
        hooks,
        mirror,
    };
    return { ...partial, suggested_next_step: suggestNextStep(partial) };
}
function inferMcpToolId(filePath) {
    const n = filePath.toLowerCase();
    if (n.includes(".cursor"))
        return "cursor";
    if (n.includes("claude_desktop"))
        return "claude_desktop";
    if (n.includes(".claude"))
        return "claude_code";
    if (n.includes(".codex"))
        return "codex";
    return path.basename(filePath);
}
//# sourceMappingURL=doctor.js.map