import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const {
  DEFAULT_MODE,
  RUNTIME_MODES,
  getDefaultMode,
  getQuietStartup,
  getHideStatus,
  normalizeMode,
  normalizePersistedMode,
  isDeactivationCommand,
  writeDefaultMode,
} = require("../hooks/ponytail-config.js");
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");

export { filterSkillBodyForMode };
export const readDefaultMode = getDefaultMode;
export const readQuietStartup = getQuietStartup;

const RUNTIME_MODE_LIST = RUNTIME_MODES.join("|");
const PONYTAIL_COMMAND_DESCRIPTION = `Set mode: ${RUNTIME_MODE_LIST}. Commands: status, default <mode>`;

export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
  const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
  if (!Array.isArray(entries)) return fallback;

  for (let i = entries.length - 1; i >= 0; i -= 1) {
    const entry = entries[i];
    if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;

    const mode = normalizePersistedMode(entry?.data?.mode);
    if (mode) return mode;
  }

  return fallback;
}

export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
  const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
  const normalizedText = String(text || "").trim().toLowerCase();

  if (!normalizedText) {
    return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
  }

  const [primary, secondary] = normalizedText.split(/\s+/);

  if (primary === "status") return { type: "status" };

  if (primary === "default") {
    // ponytail: a default must be a runtime level; review is session-only (#377).
    const mode = normalizeMode(secondary);
    return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
  }

  const mode = normalizeMode(primary);
  return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
}

export { writeDefaultMode };

export default function ponytailExtension(pi) {
  let currentMode = DEFAULT_MODE;
  let configuredDefaultMode = getDefaultMode();
  let hideStatus = getHideStatus();
  let isActive = false;
  let lastCtx = null;

  // -- Status bar --
  function syncStatus(ctx) {
    if (ctx) lastCtx = ctx;
    const c = ctx || lastCtx;
    // ponytail: hide the indicator but keep the ruleset active (#324).
    if (hideStatus) return;
    if (!c?.ui?.setStatus) return;
    // ponytail: try/catch guards against pi-web theme proxy throwing before initTheme
    let theme;
    try { theme = c.ui.theme; if (!theme?.fg) return; } catch { return; }
    if (currentMode === "off") {
      c.ui.setStatus("ponytail", "");
      return;
    }
    const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
    const icon = levelIcons[currentMode] || "";
    const label = currentMode.toUpperCase();
    const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
    c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
  }

  const setMode = (mode, ctx) => {
    const normalized = normalizePersistedMode(mode);
    if (!normalized) return;

    currentMode = normalized;
    pi.appendEntry("ponytail-mode", { mode: normalized });
    syncStatus(ctx);
    ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
  };

  const sendAlias = (skillName, args, ctx) => {
    const normalized = String(args || "").trim();
    const message = normalized ? `${skillName} ${normalized}` : skillName;

    if (ctx?.isIdle?.() === false) {
      pi.sendUserMessage(message, { deliverAs: "followUp" });
      ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
      return;
    }

    pi.sendUserMessage(message);
  };

  pi.registerCommand("ponytail", {
    description: PONYTAIL_COMMAND_DESCRIPTION,
    handler: async (args, ctx) => {
      const parsed = parsePonytailCommand(args, configuredDefaultMode);

      if (parsed.type === "status") {
        ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
        return;
      }

      if (parsed.type === "set-default") {
        try {
          const written = writeDefaultMode(parsed.mode);
          if (written) {
            configuredDefaultMode = getDefaultMode();
            const message = configuredDefaultMode === written
              ? `Default Ponytail mode set to ${written}.`
              : `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
            ctx?.ui?.notify?.(message, "info");
          }
        } catch (e) {
          ctx?.ui?.notify?.(`Failed to save default mode: ${e.message}`, "error");
        }
        return;
      }

      if (parsed.type === "set-mode") {
        setMode(parsed.mode, ctx);
        return;
      }

      ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
    },
  });

  pi.registerCommand("ponytail-review", {
    description: "Run /skill:ponytail-review",
    handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx),
  });

  pi.registerCommand("ponytail-audit", {
    description: "Run /skill:ponytail-audit",
    handler: (_args, ctx) => sendAlias("/skill:ponytail-audit", "", ctx),
  });

  pi.registerCommand("ponytail-gain", {
    description: "Run /skill:ponytail-gain",
    handler: (_args, ctx) => sendAlias("/skill:ponytail-gain", "", ctx),
  });

  pi.registerCommand("ponytail-debt", {
    description: "Run /skill:ponytail-debt",
    handler: (_args, ctx) => sendAlias("/skill:ponytail-debt", "", ctx),
  });

  pi.registerCommand("ponytail-help", {
    description: "Run /skill:ponytail-help",
    handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx),
  });

  pi.on("input", async (event) => {
    if (event?.source === "extension") return;

    const text = String(event?.text || "");
    if (currentMode !== "off" && isDeactivationCommand(text)) {
      setMode("off");
    }
  });

  pi.on("session_start", async (_event, ctx) => {
    const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
    configuredDefaultMode = getDefaultMode();
    hideStatus = getHideStatus();
    currentMode = resolveSessionMode(entries, configuredDefaultMode);
    syncStatus(ctx);
    if (!getQuietStartup()) {
      ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
    }
  });

  pi.on("agent_start", async (_event, ctx) => {
    isActive = true;
    syncStatus(ctx);
  });

  pi.on("agent_end", async (_event, ctx) => {
    isActive = false;
    syncStatus(ctx);
  });

  pi.on("before_agent_start", async (event) => {
    if (!currentMode || currentMode === "off") return;
    // Guard a null/undefined event or a missing systemPrompt: don't crash, and
    // don't prepend the literal string "undefined" to the prompt (#439, #440).
    const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
    return { systemPrompt: `${base}${getPonytailInstructions(currentMode)}` };
  });
}


package io.unitycatalog.server.service;

import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.server.annotation.Delete;
import com.linecorp.armeria.server.annotation.ExceptionHandler;
import com.linecorp.armeria.server.annotation.Get;
import com.linecorp.armeria.server.annotation.Param;
import com.linecorp.armeria.server.annotation.Patch;
import com.linecorp.armeria.server.annotation.Post;
import io.unitycatalog.server.auth.UnityCatalogAuthorizer;
import io.unitycatalog.server.auth.annotation.AuthorizeExpression;
import io.unitycatalog.server.auth.annotation.ResponseAuthorizeFilter;
import io.unitycatalog.server.auth.annotation.AuthorizeResourceKey;
import io.unitycatalog.server.auth.annotation.AuthorizeResourceKeys;
import io.unitycatalog.server.exception.GlobalExceptionHandler;
import io.unitycatalog.server.model.CreateModelVersion;
import io.unitycatalog.server.model.CreateRegisteredModel;
import io.unitycatalog.server.model.FinalizeModelVersion;
import io.unitycatalog.server.model.ListRegisteredModelsResponse;
import io.unitycatalog.server.model.ModelVersionInfo;
import io.unitycatalog.server.model.RegisteredModelInfo;
import io.unitycatalog.server.model.SchemaInfo;
import io.unitycatalog.server.model.SecurableType;
import io.unitycatalog.server.model.UpdateModelVersion;
import io.unitycatalog.server.model.UpdateRegisteredModel;
import io.unitycatalog.server.persist.CatalogRepository;
import io.unitycatalog.server.persist.MetastoreRepository;
import io.unitycatalog.server.persist.ModelRepository;
import io.unitycatalog.server.persist.Repositories;
import io.unitycatalog.server.persist.SchemaRepository;
import io.unitycatalog.server.utils.ServerProperties;

import java.util.Optional;

import lombok.SneakyThrows;

import static io.unitycatalog.server.model.SecurableType.CATALOG;
import static io.unitycatalog.server.model.SecurableType.METASTORE;
import static io.unitycatalog.server.model.SecurableType.REGISTERED_MODEL;
import static io.unitycatalog.server.model.SecurableType.SCHEMA;

@ExceptionHandler(GlobalExceptionHandler.class)
public class ModelService extends AuthorizedService {

  private final ModelRepository modelRepository;
  private final SchemaRepository schemaRepository;
  private final CatalogRepository catalogRepository;
  private final MetastoreRepository metastoreRepository;

  @SneakyThrows
  public ModelService(
      UnityCatalogAuthorizer authorizer,
      Repositories repositories,
      ServerProperties serverProperties) {
    super(authorizer, repositories, serverProperties);
    this.catalogRepository = repositories.getCatalogRepository();
    this.schemaRepository = repositories.getSchemaRepository();
    this.modelRepository = repositories.getModelRepository();
    this.metastoreRepository = repositories.getMetastoreRepository();
  }

  @Post("")
  @AuthorizeExpression("""
      (#authorizeAny(#principal, #catalog, OWNER, USE_CATALOG) &&
          #authorize(#principal, #schema, OWNER)) ||
      (#authorizeAny(#principal, #catalog, OWNER, USE_CATALOG) &&
          #authorizeAll(#principal, #schema, USE_SCHEMA, CREATE_MODEL)) ||
      (#authorizeAny(#principal, #catalog, OWNER, USE_CATALOG) &&
          #authorizeAll(#principal, #schema, USE_SCHEMA, CREATE_FUNCTION))
      """)
  public HttpResponse createRegisteredModel(
      @AuthorizeResourceKeys({
        @AuthorizeResourceKey(value = SCHEMA, key = "schema_name"),
        @AuthorizeResourceKey(value = CATALOG, key = "catalog_name")
      })
      CreateRegisteredModel createRegisteredModel) {
    assert createRegisteredModel != null;
    RegisteredModelInfo createRegisteredModelResponse =
        modelRepository.createRegisteredModel(createRegisteredModel);

    String catalogName = createRegisteredModelResponse.getCatalogName();
    String schemaName = createRegisteredModelResponse.getSchemaName();
    SchemaInfo schemaInfo = schemaRepository.getSchema(catalogName + "." + schemaName);
    String modelId = createRegisteredModelResponse.getId();
    initializeHierarchicalAuthorization(modelId, schemaInfo.getSchemaId());

    return HttpResponse.ofJson(createRegisteredModelResponse);
  }

  private static final String LIST_AND_GET_AUTH_EXPRESSION = """
      #authorize(#principal, #metastore, OWNER) ||
      #authorize(#principal, #catalog, OWNER) ||
      (#authorize(#principal, #catalog, USE_CATALOG) && #authorize(#principal, #schema, OWNER)) ||
      (#authorizeAny(#principal, #registered_model, OWNER, EXECUTE) &&
          #authorize(#principal, #schema, USE_SCHEMA) &&
          #authorize(#principal, #catalog, USE_CATALOG))
      """;

  @Get("")
  @AuthorizeExpression(LIST_AND_GET_AUTH_EXPRESSION)
  @ResponseAuthorizeFilter
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse listRegisteredModels(
      @Param("catalog_name") Optional<String> catalogName,
      @Param("schema_name") Optional<String> schemaName,
      @Param("max_results") Optional<Integer> maxResults,
      @Param("page_token") Optional<String> pageToken) {
    ListRegisteredModelsResponse listRegisteredModelsResponse =
        modelRepository.listRegisteredModels(catalogName, schemaName, maxResults, pageToken);
    applyResponseFilter(
        SecurableType.REGISTERED_MODEL, listRegisteredModelsResponse.getRegisteredModels());
    return HttpResponse.ofJson(listRegisteredModelsResponse);
  }

  @Get("/{full_name}")
  @AuthorizeExpression(LIST_AND_GET_AUTH_EXPRESSION)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse getRegisteredModel(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullNameArg) {
    assert fullNameArg != null;
    RegisteredModelInfo registeredModelInfo = modelRepository.getRegisteredModel(fullNameArg);
    return HttpResponse.ofJson(registeredModelInfo);
  }

  @Patch("/{full_name}")
  @AuthorizeExpression("""
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorizeAny(#principal, #schema, OWNER, USE_SCHEMA) &&
          #authorizeAny(#principal, #catalog, OWNER, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse updateRegisteredModel(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      UpdateRegisteredModel updateRegisteredModel) {
    assert updateRegisteredModel != null;
    RegisteredModelInfo updateRegisteredModelResponse =
        modelRepository.updateRegisteredModel(fullName, updateRegisteredModel);
    return HttpResponse.ofJson(updateRegisteredModelResponse);
  }

  @Delete("/{full_name}")
  @AuthorizeExpression("""
      #authorize(#principal, #metastore, OWNER) ||
      #authorize(#principal, #catalog, OWNER) ||
      (#authorize(#principal, #catalog, USE_CATALOG) && #authorize(#principal, #schema, OWNER)) ||
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorize(#principal, #schema, USE_SCHEMA) &&
          #authorize(#principal, #catalog, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse deleteRegisteredModel(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      @Param("force") Optional<Boolean> force) {
    RegisteredModelInfo registeredModelInfo = modelRepository.getRegisteredModel(fullName);
    modelRepository.deleteRegisteredModel(fullName, force.orElse(false));

    SchemaInfo schemaInfo =
        schemaRepository.getSchema(
            registeredModelInfo.getCatalogName() + "." + registeredModelInfo.getSchemaName());
    removeHierarchicalAuthorizations(registeredModelInfo.getId(), schemaInfo.getSchemaId());

    return HttpResponse.of(HttpStatus.OK);
  }

  @Post("/versions")
  @AuthorizeExpression("""
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorizeAny(#principal, #schema, OWNER, USE_SCHEMA) &&
          #authorizeAny(#principal, #catalog, OWNER, USE_CATALOG))
      """)
  public HttpResponse createModelVersion(
      @AuthorizeResourceKeys({
        @AuthorizeResourceKey(value = CATALOG, key = "catalog_name"),
        @AuthorizeResourceKey(value = SCHEMA, key = "schema_name"),
        @AuthorizeResourceKey(value = REGISTERED_MODEL, key = "model_name")
      })
      CreateModelVersion createModelVersion) {
    assert createModelVersion != null;
    assert createModelVersion.getModelName() != null;
    assert createModelVersion.getCatalogName() != null;
    assert createModelVersion.getSchemaName() != null;
    assert createModelVersion.getSource() != null;
    ModelVersionInfo createModelVersionResponse =
        modelRepository.createModelVersion(createModelVersion);
    return HttpResponse.ofJson(createModelVersionResponse);
  }

  @Get("/{full_name}/versions")
  @AuthorizeExpression("""
      #authorize(#principal, #metastore, OWNER) ||
      #authorize(#principal, #catalog, OWNER) ||
      (#authorize(#principal, #catalog, USE_CATALOG) && #authorize(#principal, #schema, OWNER)) ||
      (#authorizeAny(#principal, #registered_model, OWNER, EXECUTE) &&
          #authorize(#principal, #schema, USE_SCHEMA) &&
          #authorize(#principal, #catalog, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse listModelVersions(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      @Param("max_results") Optional<Integer> maxResults,
      @Param("page_token") Optional<String> pageToken) {
    return HttpResponse.ofJson(modelRepository.listModelVersions(fullName, maxResults, pageToken));
  }

  @Get("/{full_name}/versions/{version}")
  @AuthorizeExpression("""
      #authorize(#principal, #metastore, OWNER) ||
      #authorize(#principal, #catalog, OWNER) ||
      (#authorize(#principal, #catalog, USE_CATALOG) && #authorize(#principal, #schema, OWNER)) ||
      (#authorizeAny(#principal, #registered_model, OWNER, EXECUTE) &&
          #authorize(#principal, #schema, USE_SCHEMA) &&
          #authorize(#principal, #catalog, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse getModelVersion(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      @Param("version") Long version) {
    assert fullName != null && version != null;
    ModelVersionInfo modelVersionInfo = modelRepository.getModelVersion(fullName, version);
    return HttpResponse.ofJson(modelVersionInfo);
  }

  @Patch("/{full_name}/versions/{version}")
  @AuthorizeExpression("""
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorizeAny(#principal, #schema, OWNER, USE_SCHEMA) &&
          #authorizeAny(#principal, #catalog, OWNER, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse updateModelVersion(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      @Param("version") Long version,
      UpdateModelVersion updateModelVersion) {
    assert updateModelVersion != null;
    ModelVersionInfo updateModelVersionResponse =
        modelRepository.updateModelVersion(fullName, version, updateModelVersion);
    return HttpResponse.ofJson(updateModelVersionResponse);
  }

  @Delete("/{full_name}/versions/{version}")
  @AuthorizeExpression("""
      #authorize(#principal, #metastore, OWNER) ||
      #authorize(#principal, #catalog, OWNER) ||
      (#authorize(#principal, #catalog, USE_CATALOG) && #authorize(#principal, #schema, OWNER)) ||
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorize(#principal, #schema, USE_SCHEMA) &&
          #authorize(#principal, #catalog, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse deleteModelVersion(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      @Param("version") Long version) {
    modelRepository.deleteModelVersion(fullName, version);
    return HttpResponse.of(HttpStatus.OK);
  }

  @Patch("/{full_name}/versions/{version}/finalize")
  @AuthorizeExpression("""
      (#authorize(#principal, #registered_model, OWNER) &&
          #authorizeAny(#principal, #schema, OWNER, USE_SCHEMA) &&
          #authorizeAny(#principal, #catalog, OWNER, USE_CATALOG))
      """)
  @AuthorizeResourceKey(METASTORE)
  public HttpResponse finalizeModelVersion(
      @Param("full_name") @AuthorizeResourceKey(REGISTERED_MODEL) String fullName,
      FinalizeModelVersion finalizeModelVersion) {
    assert finalizeModelVersion != null;
    ModelVersionInfo finalizeModelVersionResponse =
        modelRepository.finalizeModelVersion(finalizeModelVersion);
    return HttpResponse.ofJson(finalizeModelVersionResponse);
  }

}


import { Command } from "commander";
import pc from "picocolors";
import ora from "ora";
import { checkboxWithHover } from "../utils/prompts.js";
import { log } from "../utils/logger.js";
import { trackEvent } from "../utils/tracking.js";
import { ALL_AGENT_NAMES, SETUP_AGENT_NAMES, getAgent, type SetupAgent } from "../setup/agents.js";
import {
  readJsonConfig,
  readTomlServerExists,
  removeServerEntry,
  writeJsonConfig,
  resolveMcpPath,
  removeTomlServer,
} from "../setup/mcp-writer.js";
import { join } from "path";
import { access, readFile, rm, writeFile } from "fs/promises";

type Scope = "global" | "project";
type UninstallMode = "mcp" | "cli";

interface UninstallOptions {
  claude?: boolean;
  cursor?: boolean;
  opencode?: boolean;
  codex?: boolean;
  antigravity?: boolean;
  gemini?: boolean;
  project?: boolean;
  yes?: boolean;
  all?: boolean;
  cli?: boolean;
  mcp?: boolean;
}

interface CleanupStatus {
  status: string;
  path: string;
}

interface SkillCleanupStatus extends CleanupStatus {
  name: string;
}

interface AgentCleanupResult {
  agent: string;
  mcp?: CleanupStatus;
  rule?: CleanupStatus;
  skills?: SkillCleanupStatus[];
}

const CHECKBOX_THEME = {
  style: {
    highlight: (text: string) => pc.green(text),
    disabledChoice: (text: string) => ` ${pc.dim("◯")} ${pc.dim(text)}`,
  },
};

const CONTEXT7_SECTION_MARKER = "<!-- context7 -->";
const MODE_SKILLS: Record<UninstallMode, readonly string[]> = {
  mcp: ["context7-mcp"],
  cli: ["find-docs"],
};

const MODE_LABELS: Record<UninstallMode, string> = {
  mcp: "MCP",
  cli: "CLI + Skills",
};

export function registerRemoveCommand(program: Command): void {
  program
    .command("remove")
    .alias("uninstall")
    .description("Remove Context7 setup from your AI coding agent")
    .option("--claude", "Remove from Claude Code")
    .option("--cursor", "Remove from Cursor")
    .option("--opencode", "Remove from OpenCode")
    .option("--codex", "Remove from Codex")
    .option("--antigravity", "Remove from Antigravity")
    .option("--gemini", "Remove from Gemini CLI")
    .option("--all", "Remove both MCP setup and CLI + Skills setup")
    .option("--mcp", "Remove MCP setup")
    .option("--cli", "Remove CLI + Skills setup")
    .option("-p, --project", "Remove from the current project instead of global config")
    .option("-y, --yes", "Skip confirmation prompts")
    .action(async (options: UninstallOptions) => {
      await removeCommand(options);
    });
}

function getSelectedAgents(options: UninstallOptions): SetupAgent[] {
  const agents: SetupAgent[] = [];
  if (options.claude) agents.push("claude");
  if (options.cursor) agents.push("cursor");
  if (options.opencode) agents.push("opencode");
  if (options.codex) agents.push("codex");
  if (options.antigravity) agents.push("antigravity");
  if (options.gemini) agents.push("gemini");
  return agents;
}

async function promptAgents(detected: SetupAgent[]): Promise<SetupAgent[] | null> {
  const choices = detected.map((name) => ({
    name: SETUP_AGENT_NAMES[name],
    value: name,
  }));

  if (detected.length > 0) {
    log.dim(`Detected: ${detected.map((agent) => SETUP_AGENT_NAMES[agent]).join(", ")}`);
  }

  try {
    return await checkboxWithHover(
      {
        message: "Which agents do you want to remove Context7 setup from?",
        choices,
        loop: false,
        theme: CHECKBOX_THEME,
      },
      { getName: (agent: SetupAgent) => SETUP_AGENT_NAMES[agent] }
    );
  } catch {
    return null;
  }
}

async function promptModes(modes: UninstallMode[]): Promise<UninstallMode[] | null> {
  const choices = modes.map((mode) => ({
    name: MODE_LABELS[mode],
    value: mode,
  }));

  try {
    return await checkboxWithHover(
      {
        message: "Which Context7 setup modes do you want to remove?",
        choices,
        loop: false,
        theme: CHECKBOX_THEME,
      },
      { getName: (mode: UninstallMode) => MODE_LABELS[mode] }
    );
  } catch {
    return null;
  }
}

async function resolveAgents(options: UninstallOptions, scope: Scope): Promise<SetupAgent[]> {
  const explicit = getSelectedAgents(options);
  if (explicit.length > 0) return explicit;

  const detected = await detectConfiguredAgents(scope);
  if (detected.length > 0 && options.yes) return detected;

  if (detected.length === 0) {
    log.warn(
      "No Context7 setup detected. Pass --claude, --cursor, --opencode, --codex, --antigravity, or --gemini."
    );
    return [];
  }

  log.blank();
  const selected = await promptAgents(detected);
  if (!selected) {
    log.warn("Remove cancelled");
    return [];
  }

  return selected;
}

function resolveFlagModes(options: UninstallOptions): UninstallMode[] {
  if (options.all) return ["mcp", "cli"];

  const selected: UninstallMode[] = [];

  if (options.mcp) selected.push("mcp");
  if (options.cli) selected.push("cli");

  return selected.length > 0 ? selected : ["mcp", "cli"];
}

async function pathExists(path: string): Promise<boolean> {
  try {
    await access(path);
    return true;
  } catch {
    return false;
  }
}

async function hasMcpConfig(agentName: SetupAgent, scope: Scope): Promise<boolean> {
  const agent = getAgent(agentName);
  // Agents with no project-level MCP (e.g. Antigravity) only have a global
  // config — there's nothing to detect at project scope.
  if (scope === "project" && agent.mcp.projectPaths.length === 0) return false;
  const candidates =
    scope === "global"
      ? agent.mcp.globalPaths
      : agent.mcp.projectPaths.map((path) => join(process.cwd(), path));
  const mcpPath = await resolveMcpPath(candidates);

  if (mcpPath.endsWith(".toml")) {
    return readTomlServerExists(mcpPath, "context7");
  }

  let existing: Record<string, unknown>;
  try {
    existing = await readJsonConfig(mcpPath);
  } catch (err) {
    log.warn(
      `Skipped ${mcpPath}: could not parse (${err instanceof Error ? err.message : String(err)})`
    );
    return false;
  }
  const section = existing[agent.mcp.configKey];
  return (
    !!section && typeof section === "object" && !Array.isArray(section) && "context7" in section
  );
}

async function hasRule(agentName: SetupAgent, scope: Scope): Promise<boolean> {
  const agent = getAgent(agentName);
  const rule = agent.rule;

  if (rule.kind === "file") {
    const ruleDir =
      scope === "global" ? rule.dir("global") : join(process.cwd(), rule.dir("project"));
    return pathExists(join(ruleDir, rule.filename));
  }

  const filePath =
    scope === "global" ? rule.file("global") : join(process.cwd(), rule.file("project"));

  try {
    const existing = await readFile(filePath, "utf-8");
    return existing.includes(CONTEXT7_SECTION_MARKER);
  } catch {
    return false;
  }
}

async function hasSkill(agentName: SetupAgent, scope: Scope, skillName: string): Promise<boolean> {
  const agent = getAgent(agentName);
  const skillsDir =
    scope === "global"
      ? agent.skill.dir("global")
      : join(process.cwd(), agent.skill.dir("project"));
  return pathExists(join(skillsDir, skillName));
}

async function detectAvailableModes(agents: SetupAgent[], scope: Scope): Promise<UninstallMode[]> {
  let hasMcpArtifacts = false;
  let hasCliArtifacts = false;
  let hasRuleArtifacts = false;

  for (const agent of agents) {
    hasMcpArtifacts =
      hasMcpArtifacts ||
      (await hasMcpConfig(agent, scope)) ||
      (await hasSkill(agent, scope, MODE_SKILLS.mcp[0]));
    hasCliArtifacts = hasCliArtifacts || (await hasSkill(agent, scope, MODE_SKILLS.cli[0]));
    hasRuleArtifacts = hasRuleArtifacts || (await hasRule(agent, scope));
  }

  const modes: UninstallMode[] = [];
  if (hasMcpArtifacts) modes.push("mcp");
  if (hasCliArtifacts) modes.push("cli");

  if (modes.length === 0 && hasRuleArtifacts) {
    return ["mcp", "cli"];
  }

  return modes;
}

async function hasAnyContext7Artifacts(agent: SetupAgent, scope: Scope): Promise<boolean> {
  return (
    (await hasMcpConfig(agent, scope)) ||
    (await hasRule(agent, scope)) ||
    (await hasSkill(agent, scope, MODE_SKILLS.mcp[0])) ||
    (await hasSkill(agent, scope, MODE_SKILLS.cli[0]))
  );
}

async function detectConfiguredAgents(scope: Scope): Promise<SetupAgent[]> {
  const detected: SetupAgent[] = [];

  for (const agent of ALL_AGENT_NAMES) {
    if (await hasAnyContext7Artifacts(agent, scope)) {
      detected.push(agent);
    }
  }

  return detected;
}

async function resolveModes(
  options: UninstallOptions,
  agents: SetupAgent[],
  scope: Scope
): Promise<UninstallMode[]> {
  if (options.all || options.mcp || options.cli) {
    return resolveFlagModes(options);
  }

  const detectedModes = await detectAvailableModes(agents, scope);
  if (detectedModes.length <= 1) {
    return detectedModes.length === 1 ? detectedModes : ["mcp", "cli"];
  }

  if (options.yes) {
    return detectedModes;
  }

  log.blank();
  const selected = await promptModes(detectedModes);
  if (!selected) {
    log.warn("Remove cancelled");
    return [];
  }

  return selected;
}

async function uninstallMcp(agentName: SetupAgent, scope: Scope): Promise<CleanupStatus> {
  const agent = getAgent(agentName);
  if (scope === "project" && agent.mcp.projectPaths.length === 0) {
    return { status: "not found", path: "" };
  }
  const mcpCandidates =
    scope === "global"
      ? agent.mcp.globalPaths
      : agent.mcp.projectPaths.map((path) => join(process.cwd(), path));
  const mcpPath = await resolveMcpPath(mcpCandidates);

  try {
    if (mcpPath.endsWith(".toml")) {
      const { removed } = await removeTomlServer(mcpPath, "context7");
      return { status: removed ? "removed" : "not found", path: mcpPath };
    }

    const existing = await readJsonConfig(mcpPath);
    const { config, removed } = removeServerEntry(existing, agent.mcp.configKey, "context7");
    if (removed) {
      await writeJsonConfig(mcpPath, config);
    }
    return { status: removed ? "removed" : "not found", path: mcpPath };
  } catch (err) {
    return { status: `failed: ${err instanceof Error ? err.message : String(err)}`, path: mcpPath };
  }
}

async function uninstallRule(agentName: SetupAgent, scope: Scope): Promise<CleanupStatus> {
  const agent = getAgent(agentName);
  const rule = agent.rule;

  if (rule.kind === "file") {
    const rulePath =
      scope === "global" ? rule.dir("global") : join(process.cwd(), rule.dir("project"));
    const targetPath = join(rulePath, rule.filename);

    try {
      await rm(targetPath);
      return { status: "removed", path: targetPath };
    } catch (err) {
      const error = err as NodeJS.ErrnoException;
      if (error.code === "ENOENT") return { status: "not found", path: targetPath };
      return { status: `failed: ${error.message}`, path: targetPath };
    }
  }

  const filePath =
    scope === "global" ? rule.file("global") : join(process.cwd(), rule.file("project"));

  try {
    const existing = await readFile(filePath, "utf-8");
    if (!existing.includes(CONTEXT7_SECTION_MARKER)) {
      return { status: "not found", path: filePath };
    }

    const escapedMarker = CONTEXT7_SECTION_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const updated = existing
      .replace(new RegExp(`\\n?${escapedMarker}\\n[\\s\\S]*?${escapedMarker}\\n?`, "m"), "")
      .replace(/\n{3,}/g, "\n\n")
      .replace(/^\n+/, "")
      .trimEnd();

    if (updated.length === 0) {
      await rm(filePath);
    } else {
      await writeFile(filePath, `${updated}\n`, "utf-8");
    }

    return { status: "removed", path: filePath };
  } catch (err) {
    const error = err as NodeJS.ErrnoException;
    if (error.code === "ENOENT") return { status: "not found", path: filePath };
    return { status: `failed: ${error.message}`, path: filePath };
  }
}

async function uninstallSkills(
  agentName: SetupAgent,
  scope: Scope,
  skillNames: readonly string[]
): Promise<SkillCleanupStatus[]> {
  const agent = getAgent(agentName);
  const skillsDir =
    scope === "global"
      ? agent.skill.dir("global")
      : join(process.cwd(), agent.skill.dir("project"));

  const results: SkillCleanupStatus[] = [];

  for (const skillName of skillNames) {
    const skillPath = join(skillsDir, skillName);
    try {
      await rm(skillPath, { recursive: true });
      results.push({ name: skillName, status: "removed", path: skillPath });
    } catch (err) {
      const error = err as NodeJS.ErrnoException;
      if (error.code === "ENOENT") {
        results.push({ name: skillName, status: "not found", path: skillPath });
      } else {
        results.push({ name: skillName, status: `failed: ${error.message}`, path: skillPath });
      }
    }
  }

  return results;
}

async function uninstallAgent(
  agentName: SetupAgent,
  scope: Scope,
  modes: UninstallMode[]
): Promise<AgentCleanupResult> {
  const result: AgentCleanupResult = { agent: getAgent(agentName).displayName };
  const skillNames = Array.from(new Set(modes.flatMap((mode) => [...MODE_SKILLS[mode]])));
  const shouldRemoveRule = modes.includes("mcp") || modes.includes("cli");

  if (modes.includes("mcp")) {
    result.mcp = await uninstallMcp(agentName, scope);
  }

  if (shouldRemoveRule) {
    result.rule = await uninstallRule(agentName, scope);
  }

  if (skillNames.length > 0) {
    result.skills = await uninstallSkills(agentName, scope, skillNames);
  }

  return result;
}

function iconForStatus(status: string): string {
  if (status === "removed") return pc.green("-");
  if (status === "not found") return pc.dim("~");
  return pc.red("!");
}

function printResults(results: AgentCleanupResult[], modes: UninstallMode[]): void {
  log.blank();
  const shouldPrintRule = modes.includes("mcp") || modes.includes("cli");
  let hasVisibleResults = false;

  for (const result of results) {
    const visibleSkills = result.skills?.filter((skill) => skill.status !== "not found") ?? [];
    const showMcp = modes.includes("mcp") && result.mcp && result.mcp.status !== "not found";
    const showRule = shouldPrintRule && result.rule && result.rule.status !== "not found";

    if (!showMcp && !showRule && visibleSkills.length === 0) {
      continue;
    }

    hasVisibleResults = true;
    log.plain(`  ${pc.bold(result.agent)}`);

    if (showMcp && result.mcp) {
      log.plain(`    ${iconForStatus(result.mcp.status)} MCP config ${result.mcp.status}`);
      log.plain(`      ${pc.dim(result.mcp.path)}`);
    }

    if (showRule && result.rule) {
      log.plain(`    ${iconForStatus(result.rule.status)} Rule ${result.rule.status}`);
      log.plain(`      ${pc.dim(result.rule.path)}`);
    }

    for (const skill of visibleSkills) {
      log.plain(`    ${iconForStatus(skill.status)} Skill ${skill.name} ${skill.status}`);
      log.plain(`      ${pc.dim(skill.path)}`);
    }
  }

  if (hasVisibleResults) {
    log.blank();
  } else {
    log.plain(`  ${pc.dim("No matching Context7 setup was found to remove.")}`);
    log.blank();
  }
}

async function removeCommand(options: UninstallOptions): Promise<void> {
  trackEvent("command", { name: "remove" });

  const scope: Scope = options.project ? "project" : "global";
  const agents = await resolveAgents(options, scope);
  if (agents.length === 0) return;
  const modes = await resolveModes(options, agents, scope);
  if (modes.length === 0) return;

  log.blank();
  const spinner = ora("Removing Context7 setup...").start();

  const results: AgentCleanupResult[] = [];
  for (const agentName of agents) {
    spinner.text = `Cleaning up ${getAgent(agentName).displayName}...`;
    results.push(await uninstallAgent(agentName, scope, modes));
  }

  spinner.succeed("Context7 cleanup complete");
  printResults(results, modes);

  trackEvent("remove", { agents, scope, modes });
}


package expressions

import (
	"math/rand/v2"
	"strings"

	"github.com/TecharoHQ/anubis/internal/dns"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
	"github.com/google/cel-go/ext"
)

// BotEnvironment creates a new CEL environment, this is the set of
// variables and functions that are passed into the CEL scope so that
// Anubis can fail loudly and early when something is invalid instead
// of blowing up at runtime.
func BotEnvironment(dnsObj *dns.Dns) (*cel.Env, error) {
	return New(
		// Variables exposed to CEL programs:
		cel.Variable("remoteAddress", cel.StringType),
		cel.Variable("contentLength", cel.IntType),
		cel.Variable("host", cel.StringType),
		cel.Variable("method", cel.StringType),
		cel.Variable("userAgent", cel.StringType),
		cel.Variable("path", cel.StringType),
		cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
		cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
		cel.Variable("load_1m", cel.DoubleType),
		cel.Variable("load_5m", cel.DoubleType),
		cel.Variable("load_15m", cel.DoubleType),

		// Bot-specific functions:
		cel.Function("missingHeader",
			cel.Overload("missingHeader_map_string_string_string",
				[]*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType},
				cel.BoolType,
				cel.BinaryBinding(func(headers, key ref.Val) ref.Val {
					// Convert headers to a trait that supports Find
					headersMap, ok := headers.(traits.Indexer)
					if !ok {
						return types.ValOrErr(headers, "headers is not a map, but is %T", headers)
					}

					keyStr, ok := key.(types.String)
					if !ok {
						return types.ValOrErr(key, "key is not a string, but is %T", key)
					}

					val := headersMap.Get(keyStr)
					// Check if the key is missing by testing for an error
					if types.IsError(val) {
						return types.Bool(true) // header is missing
					}
					return types.Bool(false) // header is present
				}),
			),
		),

		cel.Function("reverseDNS",
			cel.Overload("reverseDNS_string_list_string",
				[]*cel.Type{cel.StringType},
				cel.ListType(cel.StringType),
				cel.UnaryBinding(func(addr ref.Val) ref.Val {
					addrStr, ok := addr.(types.String)
					if !ok {
						return types.ValOrErr(addr, "addr is not a string, but is %T", addr)
					}

					names, err := dnsObj.ReverseDNS(string(addrStr))
					if err != nil {
						return types.NewStringList(types.DefaultTypeAdapter, []string{})
					}
					return types.NewStringList(types.DefaultTypeAdapter, names)
				}),
			),
		),

		cel.Function("lookupHost",
			cel.Overload("lookupHost_string_list_string",
				[]*cel.Type{cel.StringType},
				cel.ListType(cel.StringType),
				cel.UnaryBinding(func(host ref.Val) ref.Val {
					hostStr, ok := host.(types.String)
					if !ok {
						return types.ValOrErr(host, "host is not a string, but is %T", host)
					}

					addrs, err := dnsObj.LookupHost(string(hostStr))
					if err != nil {
						return types.NewStringList(types.DefaultTypeAdapter, []string{})
					}
					return types.NewStringList(types.DefaultTypeAdapter, addrs)
				}),
			),
		),

		cel.Function("verifyFCrDNS",
			cel.Overload("verifyFCrDNS_string_bool",
				[]*cel.Type{cel.StringType},
				cel.BoolType,
				cel.UnaryBinding(func(addr ref.Val) ref.Val {
					addrStr, ok := addr.(types.String)
					if !ok {
						return types.ValOrErr(addr, "addr is not a string")
					}
					return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), nil))
				}),
			),
			cel.Overload("verifyFCrDNS_string_string_bool",
				[]*cel.Type{cel.StringType, cel.StringType},
				cel.BoolType,
				cel.BinaryBinding(func(addr, pattern ref.Val) ref.Val {
					addrStr, ok := addr.(types.String)
					if !ok {
						return types.ValOrErr(addr, "addr is not a string")
					}
					patternStr, ok := pattern.(types.String)
					if !ok {
						return types.ValOrErr(pattern, "pattern is not a string")
					}
					p := string(patternStr)
					return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), &p))
				}),
			),
		),

		// arpaReverseIP transforms ip into arpa reverse notation like this
		// 1.2.3.4		->	4.3.2.1
		// 2001:db8::1  ->  1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2
		cel.Function("arpaReverseIP",
			cel.Overload("arpaReverseIP_string_string",
				[]*cel.Type{cel.StringType},
				cel.StringType,
				cel.UnaryBinding(func(addr ref.Val) ref.Val {
					s, ok := addr.(types.String)
					if !ok {
						return types.ValOrErr(addr, "addr is not a string")
					}

					reversedIp, err := dnsObj.ArpaReverseIP(string(s))
					if err != nil {
						return types.ValOrErr(addr, "%s", err.Error())
					}
					return types.String(reversedIp)
				}),
			),
		),

		// regexSafe escapes a string for insertion into a regular expression
		cel.Function("regexSafe",
			cel.Overload("regexSafe_string_string",
				[]*cel.Type{cel.StringType},
				cel.StringType,
				cel.UnaryBinding(func(str ref.Val) ref.Val {
					s, ok := str.(types.String)
					if !ok {
						return types.ValOrErr(str, "addr is not a string")
					}

					escapes := []string{"\\", ".", ":", "*", "?", "-", "[", "]", "(", ")", "+", "{", "}", "|", "^", "$"}
					r := string(s)

					for _, escape := range escapes {
						r = strings.ReplaceAll(r, escape, "\\"+escape)
					}
					return types.String(r)
				}),
			),
		),

		cel.Function("segments",
			cel.Overload("segments_string_list_string",
				[]*cel.Type{cel.StringType},
				cel.ListType(cel.StringType),
				cel.UnaryBinding(func(path ref.Val) ref.Val {
					pathStrType, ok := path.(types.String)
					if !ok {
						return types.ValOrErr(path, "path is not a string, but is %T", path)
					}

					pathStr := string(pathStrType)
					if !strings.HasPrefix(pathStr, "/") {
						return types.ValOrErr(path, "path does not start with /")
					}

					pathList := strings.Split(string(pathStr), "/")[1:]

					return types.NewStringList(types.DefaultTypeAdapter, pathList)
				}),
			),
		),
	)
}

// NewThreshold creates a new CEL environment for threshold checking.
func ThresholdEnvironment() (*cel.Env, error) {
	return New(
		cel.Variable("weight", cel.IntType),
	)
}

func New(opts ...cel.EnvOption) (*cel.Env, error) {
	args := []cel.EnvOption{
		ext.Strings(
			ext.StringsLocale("en_US"),
			ext.StringsValidateFormatCalls(true),
		),

		// default all timestamps to UTC
		cel.DefaultUTCTimeZone(true),

		// Functions exposed to all CEL programs:
		cel.Function("randInt",
			cel.Overload("randInt_int",
				[]*cel.Type{cel.IntType},
				cel.IntType,
				cel.UnaryBinding(func(val ref.Val) ref.Val {
					n, ok := val.(types.Int)
					if !ok {
						return types.ValOrErr(val, "value is not an integer, but is %T", val)
					}

					if n <= 0 {
						return types.NewErr("randInt bound must be positive, got %d", int64(n))
					}

					bound := int(n)
					if types.Int(bound) != n {
						return types.NewErr("randInt bound %d overflows platform int", int64(n))
					}

					return types.Int(rand.IntN(bound))
				}),
			),
		),
	}

	args = append(args, opts...)
	return cel.NewEnv(args...)
}

// Compile takes CEL environment and syntax tree then emits an optimized
// Program for execution.
func Compile(env *cel.Env, src string) (cel.Program, error) {
	intermediate, iss := env.Compile(src)
	if iss != nil {
		return nil, iss.Err()
	}

	ast, iss := env.Check(intermediate)
	if iss != nil {
		return nil, iss.Err()
	}

	return env.Program(
		ast,
		cel.EvalOptions(
			// optimize regular expressions right now instead of on the fly
			cel.OptOptimize,
		),
	)
}


"""Recording Watchdog for Browser Use Sessions."""

import asyncio
from pathlib import Path
from typing import Any, ClassVar

from bubus import BaseEvent
from cdp_use.cdp.page.events import ScreencastFrameEvent
from pydantic import PrivateAttr
from uuid_extensions import uuid7str

from browser_use.browser.events import AgentFocusChangedEvent, BrowserConnectedEvent, BrowserStopEvent
from browser_use.browser.profile import ViewportSize
from browser_use.browser.video_recorder import VideoRecorderService
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.utils import create_task_with_error_handling

class RecordingWatchdog(BaseWatchdog):
	"""
	Manages video recording of a browser session using CDP screencasting.
	"""

	LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent, AgentFocusChangedEvent]
	EMITS: ClassVar[list[type[BaseEvent]]] = []

	_recorder: VideoRecorderService | None = PrivateAttr(default=None)
	_current_session_id: str | None = PrivateAttr(default=None)
	_screencast_params: dict[str, Any] | None = PrivateAttr(default=None)

	async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
		"""
		Starts video recording if it is configured in the browser profile.
		"""
		profile = self.browser_session.browser_profile
		if not profile.record_video_dir:
			return

		video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
		output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
		try:
			await self.start_recording(output_path, size=profile.record_video_size, framerate=profile.record_video_framerate)
		except RuntimeError as e:
			# Preserve prior graceful degradation: a session configured with record_video_dir
			# should not fail startup when video deps are missing or viewport detection fails.
			self.logger.warning(f'Skipping video recording: {e}')

	async def start_recording(
		self,
		output_path: Path,
		size: ViewportSize | None = None,
		framerate: int | None = None,
	) -> Path:
		"""
		Begin recording the current session to `output_path`. Safe to call at any time
		after the browser has connected.

		Returns the resolved output path. Raises RuntimeError if recording is already active
		or if the viewport size could not be determined.
		"""
		if self._recorder is not None:
			raise RuntimeError(f'Recording already in progress (output: {self._recorder.output_path})')

		if size is None:
			self.logger.debug('record size not specified, detecting viewport size...')
			size = await self._get_current_viewport_size()
		if not size:
			raise RuntimeError('Cannot start video recording: viewport size could not be determined.')

		if framerate is None:
			framerate = self.browser_session.browser_profile.record_video_framerate

		output_path = Path(output_path)
		self.logger.debug(f'Initializing video recorder → {output_path}')
		recorder = VideoRecorderService(output_path=output_path, size=size, framerate=framerate)
		recorder.start()
		if not recorder._is_active:
			raise RuntimeError(
				'Failed to initialize video recorder — ensure optional deps are installed (`pip install "browser-use[video]"`).'
			)

		self._recorder = recorder
		self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
		self._screencast_params = {
			'format': 'png',
			'quality': 90,
			'maxWidth': size['width'],
			'maxHeight': size['height'],
			'everyNthFrame': 1,
		}
		await self._start_screencast()
		return output_path

	async def stop_recording(self) -> Path | None:
		"""
		Stop any in-progress recording and finalize the output file.

		Returns the path of the saved video, or None if no recording was active.
		"""
		if not self._recorder:
			return None

		recorder = self._recorder
		session_id = self._current_session_id
		self._recorder = None
		self._current_session_id = None
		self._screencast_params = None

		if session_id:
			try:
				await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=session_id)
			except Exception as e:
				self.logger.debug(f'Failed to stop CDP screencast on {session_id}: {e}')

		output_path = recorder.output_path
		loop = asyncio.get_event_loop()
		await loop.run_in_executor(None, recorder.stop_and_save)
		return output_path

	@property
	def is_recording(self) -> bool:
		"""Whether a recording is currently in progress."""
		return self._recorder is not None

	async def on_AgentFocusChangedEvent(self, event: AgentFocusChangedEvent) -> None:
		"""
		Switches video recording to the new tab.
		"""
		if self._recorder:
			self.logger.debug(f'Agent focus changed to {event.target_id}, switching screencast...')
			await self._start_screencast()

	async def _start_screencast(self) -> None:
		"""Starts screencast on the currently focused tab."""
		if not self._recorder or not self._screencast_params:
			return

		try:
			# Get the current session (for the focused target)
			cdp_session = await self.browser_session.get_or_create_cdp_session()

			# If we are already recording this session, do nothing
			if self._current_session_id == cdp_session.session_id:
				return

			# Stop recording on the previous session
			if self._current_session_id:
				try:
					# Use the root client to stop screencast on the specific session
					await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=self._current_session_id)
				except Exception as e:
					# It's possible the session is already closed
					self.logger.debug(f'Failed to stop screencast on old session {self._current_session_id}: {e}')

			self._current_session_id = cdp_session.session_id

			# Start recording on the new session
			await cdp_session.cdp_client.send.Page.startScreencast(
				params=self._screencast_params,  # type: ignore
				session_id=cdp_session.session_id,
			)
			self.logger.info(f'📹 Started/Switched video recording to target {cdp_session.target_id}')
		except Exception as e:
			self.logger.error(f'Failed to switch screencast via CDP: {e}')
			# If we fail to start on the new tab, we reset current session id
			self._current_session_id = None

	async def _get_current_viewport_size(self) -> ViewportSize | None:
		"""Gets the current viewport size directly from the browser via CDP."""
		try:
			cdp_session = await self.browser_session.get_or_create_cdp_session()
			metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)

			# Use cssVisualViewport for the most accurate representation of the visible area
			viewport = metrics.get('cssVisualViewport', {})
			width = viewport.get('clientWidth')
			height = viewport.get('clientHeight')

			if width and height:
				self.logger.debug(f'Detected viewport size: {width}x{height}')
				return ViewportSize(width=int(width), height=int(height))
		except Exception as e:
			self.logger.warning(f'Failed to get viewport size from browser: {e}')

		return None

	def on_screencastFrame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
		"""
		Synchronous handler for incoming screencast frames.
		"""
		# Only process frames from the current session we intend to record
		# This handles race conditions where old session might still send frames before stop completes
		if self._current_session_id and session_id != self._current_session_id:
			return

		if not self._recorder:
			return
		self._recorder.add_frame(event['data'])
		create_task_with_error_handling(
			self._ack_screencast_frame(event, session_id),
			name='ack_screencast_frame',
			logger_instance=self.logger,
			suppress_exceptions=True,
		)

	async def _ack_screencast_frame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
		"""
		Asynchronously acknowledges a screencast frame.
		"""
		try:
			await self.browser_session.cdp_client.send.Page.screencastFrameAck(
				params={'sessionId': event['sessionId']}, session_id=session_id
			)
		except Exception as e:
			self.logger.debug(f'Failed to acknowledge screencast frame: {e}')

	async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
		"""
		Stops the video recording and finalizes the video file.
		"""
		if self._recorder:
			self.logger.debug('Stopping video recording and saving file...')
			await self.stop_recording()


/**
 * Shared constants and helpers for Doubao desktop app (Electron + CDP).
 *
 * Requires: Doubao launched with --remote-debugging-port=9226
 */
/** Selectors discovered via data-testid attributes */
export const SEL = {
    INPUT: '[data-testid="chat_input_input"]',
    SEND_BTN: '[data-testid="chat_input_send_button"]',
    MESSAGE: '[data-testid="message_content"]',
    MESSAGE_TEXT: '[data-testid="message_text_content"]',
    INDICATOR: '[data-testid="indicator"]',
    NEW_CHAT: '[data-testid="new_chat_button"]',
    NEW_CHAT_SIDEBAR: '[data-testid="app-open-newChat"]',
};
/**
 * Inject text into the Doubao chat textarea via React-compatible value setter.
 * Returns an evaluate script string.
 */
export function injectTextScript(text) {
    return `(function(t) {
    const textarea = document.querySelector('${SEL.INPUT}');
    if (!textarea) return { ok: false, error: 'No textarea found' };
    textarea.focus();
    const setter = Object.getOwnPropertyDescriptor(
      window.HTMLTextAreaElement.prototype, 'value'
    )?.set;
    if (setter) setter.call(textarea, t);
    else textarea.value = t;
    textarea.dispatchEvent(new Event('input', { bubbles: true }));
    textarea.dispatchEvent(new Event('change', { bubbles: true }));
    return { ok: true };
  })(${JSON.stringify(text)})`;
}
/**
 * Click the send button. Returns an evaluate script string.
 */
export function clickSendScript() {
    return `(function() {
    const btn = document.querySelector('${SEL.SEND_BTN}');
    if (!btn) return false;
    btn.click();
    return true;
  })()`;
}
/**
 * Read all chat messages from the DOM. Returns an evaluate script string.
 */
export function readMessagesScript() {
    return `(function() {
    const results = [];
    const containers = document.querySelectorAll('${SEL.MESSAGE}');
    for (const container of containers) {
      const textEl = container.querySelector('${SEL.MESSAGE_TEXT}');
      if (!textEl) continue;
      // Skip streaming messages
      if (textEl.querySelector('${SEL.INDICATOR}') ||
          textEl.getAttribute('data-show-indicator') === 'true') continue;
      const isUser = container.classList.contains('justify-end');
      let text = '';
      const children = textEl.querySelectorAll('div[dir]');
      if (children.length > 0) {
        text = Array.from(children).map(c => c.innerText || c.textContent || '').join('');
      } else {
        text = textEl.innerText?.trim() || textEl.textContent?.trim() || '';
      }
      if (!text) continue;
      results.push({ role: isUser ? 'User' : 'Assistant', text: text.substring(0, 2000) });
    }
    return results;
  })()`;
}
/**
 * Click the new-chat button. Returns an evaluate script string.
 */
export function clickNewChatScript() {
    return `(function() {
    let btn = document.querySelector('${SEL.NEW_CHAT}');
    if (btn) { btn.click(); return true; }
    btn = document.querySelector('${SEL.NEW_CHAT_SIDEBAR}');
    if (btn) { btn.click(); return true; }
    return false;
  })()`;
}
/**
 * Poll for a new assistant response after sending.
 * Returns evaluate script that checks message count vs baseline.
 */
export function pollResponseScript(beforeCount) {
    return `(function(prevCount) {
    const msgs = document.querySelectorAll('${SEL.MESSAGE}');
    if (msgs.length <= prevCount) return { phase: 'waiting', text: null };
    const lastMsg = msgs[msgs.length - 1];
    if (lastMsg.classList.contains('justify-end')) return { phase: 'waiting', text: null };
    const textEl = lastMsg.querySelector('${SEL.MESSAGE_TEXT}');
    if (!textEl) return { phase: 'waiting', text: null };
    if (textEl.querySelector('${SEL.INDICATOR}') ||
        textEl.getAttribute('data-show-indicator') === 'true') {
      return { phase: 'streaming', text: null };
    }
    let text = '';
    const children = textEl.querySelectorAll('div[dir]');
    if (children.length > 0) {
      text = Array.from(children).map(c => c.innerText || c.textContent || '').join('');
    } else {
      text = textEl.innerText?.trim() || textEl.textContent?.trim() || '';
    }
    return { phase: 'done', text };
  })(${beforeCount})`;
}


.load dist/vec0main
.bail on

.mode qbox

.load ./memstat
.echo on

select name, value from sqlite_memstat where name = 'MEMORY_USED';

create virtual table v using vec0(
  vector float[1],
  name1 text,
  name2 text,
  age int,
  chunk_size=8
);

select name, value from sqlite_memstat where name = 'MEMORY_USED';

insert into v(vector, name1, name2, age) values
  ('[1]', 'alex', 'xxxx', 1),
  ('[2]', 'alex', 'aaaa', 2),
  ('[3]', 'alex', 'aaaa', 3),
  ('[4]', 'brian', 'aaaa', 1),
  ('[5]', 'brian', 'aaaa', 2),
  ('[6]', 'brian', 'aaaa', 3),
  ('[7]', 'craig', 'aaaa', 1),
  ('[8]', 'craig', 'xxxx', 2),
  ('[9]', 'craig', 'xxxx', 3),
  ('[10]', '123456789012345', 'xxxx', 3);

select name, value from sqlite_memstat where name = 'MEMORY_USED';

select rowid, name1, name2, age, vec_to_json(vector)
from v
where vector match '[0]'
  and k = 5
  and name1 in ('alex', 'brian', 'craig')
  --and name2 in ('aaaa', 'xxxx')
  and age in (1, 2, 3, 2222,3333,4444);

select name, value from sqlite_memstat where name = 'MEMORY_USED';

select rowid, name1, name2, age, vec_to_json(vector)
from v
where vector match '[0]'
  and k = 5
  and name1 in ('123456789012345', 'superfluous');

.exit

create virtual table v using vec0(
  vector float[1],
  +description text
);
insert into v(rowid, vector, description) values (1, '[1]', 'aaa');
select * from v;

.exit

create virtual table vec_articles using vec0(
  article_id integer primary key,
  year integer partition key,
  headline_embedding float[1],
  +headline text,
  +url text,
  word_count integer,
  print_section text,
  print_page integer,
  pub_date text,
);

insert into vec_articles values (1111, 2020, '[1]', 'headline', 'https://...', 200, 'A', 1, '2020-01-01');

select * from vec_articles;

.exit

create table movies(movie_id integer primary key, synopsis text);
INSERT INTO movies(movie_id, synopsis)
VALUES
  (1, 'A family is haunted by demonic spirits after moving into a new house, requiring the help of paranormal investigators.'),
  (2, 'Two dim-witted friends embark on a cross-country road trip to return a briefcase full of money to its owner.'),
  (3, 'A team of explorers travels through a wormhole in space in an attempt to ensure humanity’s survival.'),
  (4, 'A young hobbit embarks on a journey with a fellowship to destroy a powerful ring and save Middle-earth from darkness.'),
  (5, 'A documentary about the dangers of global warming, featuring former U.S. Vice President Al Gore.'),
  (6, 'After the death of her secretive mother, a woman discovers terrifying secrets about her family lineage.'),
  (7, 'A clueless but charismatic TV anchorman struggles to stay relevant in the world of broadcast journalism.'),
  (8, 'A young blade runner uncovers a long-buried secret that leads him to track down former blade runner Rick Deckard.'),
  (9, 'A young boy discovers he is a wizard and attends a magical school, where he learns about his destiny.'),
  (10, 'A rock climber attempts to scale El Capitan in Yosemite National Park without the use of ropes or safety gear.'),
  (11, 'A young African-American man uncovers a disturbing secret when he visits his white girlfriend''s family estate.'),
  (12, 'Three friends wake up from a bachelor party in Las Vegas with no memory of the previous night and must retrace their steps.'),
  (13, 'A computer hacker learns about the true nature of his reality and his role in the war against its controllers.'),
  (14, 'In post-Civil War Spain, a young girl escapes into an eerie but captivating fantasy world.'),
  (15, 'A documentary that explores racial inequality in the United States, focusing on the prison system and mass incarceration.'),
  (16, 'A young woman is followed by an unknown supernatural force after a sexual encounter.'),
  (17, 'Two immature but well-meaning stepbrothers become instant rivals when their single parents marry.'),
  (18, 'A thief with the ability to enter people''s dreams is tasked with planting an idea into a target''s subconscious.'),
  (19, 'A mute woman forms a unique relationship with a mysterious aquatic creature being held in a secret research facility.'),
  (20, 'A documentary about the life and legacy of Fred Rogers, the beloved host of the children''s TV show "Mister Rogers'' Neighborhood."');

create virtual table vec_movies using vec0(
  movie_id integer primary key,
  synopsis_embedding float[1],
  +title text,
  genre text,
  num_reviews int,
  mean_rating float,
  chunk_size=8
);

.schema
/*
insert into vec_movies(movie_id, synopsis_embedding, num_reviews, mean_rating) values
  (1, '[1]', 153, 4.6),
  (2, '[2]', 382, 2.6),
  (3, '[3]', 53, 5.0),
  (4, '[4]', 210, 4.2),
  (5, '[5]', 93, 3.4),
  (6, '[6]', 167, 4.7),
  (7, '[7]', 482, 2.9),
  (8, '[8]', 301, 5.0),
  (9, '[9]', 134, 4.1),
  (10, '[10]', 66, 3.2),
  (11, '[11]', 88, 4.9),
  (12, '[12]', 59, 2.8),
  (13, '[13]', 423, 4.5),
  (14, '[14]', 275, 3.6),
  (15, '[15]', 191, 4.4),
  (16, '[16]', 314, 4.3),
  (17, '[17]', 74, 3.0),
  (18, '[18]', 201, 5.0),
  (19, '[19]', 399, 2.7),
  (20, '[20]', 186, 4.8);
*/

/*

INSERT INTO vec_movies(movie_id, synopsis_embedding, genre, num_reviews, mean_rating)
VALUES
  (1, '[1]', 'horror', 153, 4.6),
  (2, '[2]', 'comedy', 382, 2.6),
  (3, '[3]', 'scifi', 53, 5.0),
  (4, '[4]', 'fantasy', 210, 4.2),
  (5, '[5]', 'documentary', 93, 3.4),
  (6, '[6]', 'horror', 167, 4.7),
  (7, '[7]', 'comedy', 482, 2.9),
  (8, '[8]', 'scifi', 301, 5.0),
  (9, '[9]', 'fantasy', 134, 4.1),
  (10, '[10]', 'documentary', 66, 3.2),
  (11, '[11]', 'horror', 88, 4.9),
  (12, '[12]', 'comedy', 59, 2.8),
  (13, '[13]', 'scifi', 423, 4.5),
  (14, '[14]', 'fantasy', 275, 3.6),
  (15, '[15]', 'documentary', 191, 4.4),
  (16, '[16]', 'horror', 314, 4.3),
  (17, '[17]', 'comedy', 74, 3.0),
  (18, '[18]', 'scifi', 201, 5.0),
  (19, '[19]', 'fantasy', 399, 2.7),
  (20, '[20]', 'documentary', 186, 4.8);
*/

INSERT INTO vec_movies(movie_id, synopsis_embedding, genre, title, num_reviews, mean_rating)
VALUES
  (1, '[1]', 'horror', 'The Conjuring', 153, 4.6),
  (2, '[2]', 'comedy', 'Dumb and Dumber', 382, 2.6),
  (3, '[3]', 'scifi', 'Interstellar', 53, 5.0),
  (4, '[4]', 'fantasy', 'The Lord of the Rings: The Fellowship of the Ring', 210, 4.2),
  (5, '[5]', 'documentary', 'An Inconvenient Truth', 93, 3.4),
  (6, '[6]', 'horror', 'Hereditary', 167, 4.7),
  (7, '[7]', 'comedy', 'Anchorman: The Legend of Ron Burgundy', 482, 2.9),
  (8, '[8]', 'scifi', 'Blade Runner 2049', 301, 5.0),
  (9, '[9]', 'fantasy', 'Harry Potter and the Sorcerer''s Stone', 134, 4.1),
  (10, '[10]', 'documentary', 'Free Solo', 66, 3.2),
  (11, '[11]', 'horror', 'Get Out', 88, 4.9),
  (12, '[12]', 'comedy', 'The Hangover', 59, 2.8),
  (13, '[13]', 'scifi', 'The Matrix', 423, 4.5),
  (14, '[14]', 'fantasy', 'Pan''s Labyrinth', 275, 3.6),
  (15, '[15]', 'documentary', '13th', 191, 4.4),
  (16, '[16]', 'horror', 'It Follows', 314, 4.3),
  (17, '[17]', 'comedy', 'Step Brothers', 74, 3.0),
  (18, '[18]', 'scifi', 'Inception', 201, 5.0),
  (19, '[19]', 'fantasy', 'The Shape of Water', 399, 2.7),
  (20, '[20]', 'documentary', 'Won''t You Be My Neighbor?', 186, 4.8),
  (21, '[21]', 'scifi', 'Gravity', 342, 4.0),
  (22, '[22]', 'scifi', 'Dune', 451, 4.4),
  (23, '[23]', 'scifi', 'The Martian', 522, 4.6),
  (24, '[24]', 'horror', 'A Quiet Place', 271, 4.3),
  (25, '[25]', 'fantasy', 'The Chronicles of Narnia: The Lion, the Witch and the Wardrobe', 310, 3.9);

--select * from vec_movies;
--select * from vec_movies_metadata_chunks00;

create virtual table vec_chunks using vec0(
  user_id integer partition key,
  +contents text,
  contents_embedding float[1],
);

INSERT INTO vec_chunks (rowid, user_id, contents, contents_embedding) VALUES
(1, 123, 'Our PTO policy allows employees to take both vacation and sick leave as needed.', '[1]'),
(2, 123, 'Employees must provide notice at least two weeks in advance for planned vacations.', '[2]'),
(3, 123, 'Sick leave can be taken without advance notice, but employees must inform their manager.', '[3]'),
(4, 123, 'Unused PTO can be carried over to the following year, up to a maximum of 40 hours.', '[4]'),
(5, 123, 'PTO must be used in increments of at least 4 hours.', '[5]'),
(6, 456, 'New employees are granted 10 days of PTO during their first year of employment.', '[6]'),
(7, 456, 'After the first year, employees earn an additional day of PTO for each year of service.', '[7]'),
(8, 789, 'PTO requests will be reviewed by the HR department and are subject to approval.', '[8]'),
(9, 789, 'The company reserves the right to deny PTO requests during peak operational periods.', '[9]'),
(10, 456, 'If PTO is denied, the employee will be given an alternative time to take leave.', '[10]'),
(11, 789, 'Employees who are out of PTO must request unpaid leave for any additional time off.', '[11]'),
(12, 789, 'In case of a family emergency, employees can request emergency leave.', '[12]'),
(13, 456, 'Emergency leave may be granted for personal or family illness, or other critical situations.', '[13]'),
(14, 789, 'The maximum length of emergency leave is subject to company discretion.', '[14]'),
(15, 123, 'All PTO balances will be displayed on the employee self-service portal.', '[15]'),
(16, 456, 'Employees who are terminated will be paid for unused PTO, as per state law.', '[16]'),
(17, 123, 'Part-time employees are eligible for PTO on a pro-rata basis.', '[17]'),
(18, 789, 'The company encourages employees to use their PTO to maintain work-life balance.', '[18]'),
(19, 456, 'Employees should not book travel plans until their PTO request has been approved.', '[19]'),
(20, 123, 'Managers are responsible for tracking their team members'' PTO usage.', '[20]');

select rowid, user_id, contents, distance
from vec_chunks
where contents_embedding match '[19]'
  and user_id = 123
  and k = 5;

.exit

-- PARTITION KEY and auxiliar columns!
create virtual table vec_chunks using vec0(
  -- internally shard the vector index by user
  user_id integer partition key,
  -- store the chunk text pre-embedding as an "auxiliary column"
  +contents text,
  contents_embeddings float[1024],
);

select rowid, user_id, contents, distance
from vec_chunks
where contents_embedding match '[...]'
  and user_id = 123
  and k = 5;
/*
┌───────┬─────────┬──────────────────────────────────────────────────────────────┬──────────┐
│ rowid │ user_id │                           contents                           │ distance │
├───────┼─────────┼──────────────────────────────────────────────────────────────┼──────────┤
│ 20    │ 123     │ 'Managers are responsible for tracking their team members''  │ 1.0      │
│       │         │ PTO usage.'                                                  │          │
├───────┼─────────┼──────────────────────────────────────────────────────────────┼──────────┤
│ 17    │ 123     │ 'Part-time employees are eligible for PTO on a pro-rata basi │ 2.0      │
│       │         │ s.'                                                          │          │
├───────┼─────────┼──────────────────────────────────────────────────────────────┼──────────┤
│ 15    │ 123     │ 'All PTO balances will be displayed on the employee self-ser │ 4.0      │
│       │         │ vice portal.'                                                │          │
├───────┼─────────┼──────────────────────────────────────────────────────────────┼──────────┤
│ 5     │ 123     │ 'PTO must be used in increments of at least 4 hours.'        │ 14.0     │
├───────┼─────────┼──────────────────────────────────────────────────────────────┼──────────┤
│ 4     │ 123     │ 'Unused PTO can be carried over to the following year, up to │ 15.0     │
│       │         │  a maximum of 40 hours.'                                     │          │
└───────┴─────────┴──────────────────────────────────────────────────────────────┴──────────┘
*/

-- metadata filters!
create virtual table vec_movies using vec0(
  movie_id integer primary key,
  synopsis_embedding float[1024],
  genre text,
  num_reviews int,
  mean_rating float
);

select
  movie_id,
  title,
  genre,
  num_reviews,
  mean_rating,
  distance
from vec_movies
where synopsis_embedding match '[15.5]'
  and genre = 'scifi'
  and num_reviews between 100 and 500
  and mean_rating > 3.5
  and k = 5;
/*
┌──────────┬─────────────────────┬─────────┬─────────────┬──────────────────┬──────────┐
│ movie_id │        title        │  genre  │ num_reviews │   mean_rating    │ distance │
├──────────┼─────────────────────┼─────────┼─────────────┼──────────────────┼──────────┤
│ 13       │ 'The Matrix'        │ 'scifi' │ 423         │ 4.5              │ 2.5      │
│ 18       │ 'Inception'         │ 'scifi' │ 201         │ 5.0              │ 2.5      │
│ 21       │ 'Gravity'           │ 'scifi' │ 342         │ 4.0              │ 5.5      │
│ 22       │ 'Dune'              │ 'scifi' │ 451         │ 4.40000009536743 │ 6.5      │
│ 8        │ 'Blade Runner 2049' │ 'scifi' │ 301         │ 5.0              │ 7.5      │
└──────────┴─────────────────────┴─────────┴─────────────┴──────────────────┴──────────┘
*/

.exit

create virtual table vec_movies using vec0(
  movie_id integer primary key,
  synopsis_embedding float[768],
  genre text,
  num_reviews int,
  mean_rating float,
);

.exit

create virtual table vec_chunks using vec0(
  chunk_id integer primary key,
  contents_embedding float[1],
  +contents text
);
insert into vec_chunks(chunk_id, contents_embedding, contents) values
  (1, '[1]', 'alex'),
  (2, '[2]', 'brian'),
  (3, '[3]', 'craig'),
  (4, '[4]', 'dylan');

select * from vec_chunks;

select chunk_id, contents, distance
from vec_chunks
where contents_embedding match '[5]'
and k = 3;

.exit

create virtual table v using vec0(a float[1]);
select count(*) from v_chunks;
insert into v(a) values ('[1.11]');
select * from v;
drop table v;

create virtual table v using vec0(

  v_aaa float[1],
  partk_xxx int partition key,
  v_bbb float[2],
  partk_yyy text partition key,
  chunk_size=32
);

insert into v(rowid, v_aaa, partk_xxx, v_bbb, partk_yyy) values
  (1, '[.1]', 999, '[.11, .11]', 'alex'),
  (2, '[.2]', 999, '[.22, .22]', 'alex'),
  (3, '[.3]', 999, '[.33, .33]', 'brian');

select rowid, vec_to_json(v_aaa), partk_xxx, vec_to_json(v_bbb), partk_yyy from v;

select * from v;
select * from v where rowid = 2;
update v
set v_aaa = '[.222]',
  v_bbb = '[.222, .222]'
where rowid = 2;

select rowid, vec_to_json(v_aaa), partk_xxx, vec_to_json(v_bbb), partk_yyy from v;

select chunk_id, size, sequence_id, partition00, partition01, (validity), length(rowids) from v_chunks;

--explain query plan
select *, distance
from v
where v_aaa match '[.5]'
  and partk_xxx = 999
  and partk_yyy = 'alex'
  --and partk_xxx != 20
  and k = 5;


import * as React from "react"
import {
  DayPicker,
  getDefaultClassNames,
  type DayButton,
  type Locale,
} from "react-day-picker"

import { cn } from "#/lib/utils.ts"
import { Button, buttonVariants } from "#/components/ui/button.tsx"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeftIcon, ArrowRightIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(
        "group/calendar bg-background p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: (date) =>
          date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}
      classNames={{
        root: cn("w-fit", defaultClassNames.root),
        months: cn(
          "relative flex flex-col gap-4 md:flex-row",
          defaultClassNames.months
        ),
        month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
        nav: cn(
          "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
          defaultClassNames.nav
        ),
        button_previous: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
          defaultClassNames.button_previous
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
          defaultClassNames.button_next
        ),
        month_caption: cn(
          "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
          defaultClassNames.month_caption
        ),
        dropdowns: cn(
          "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
          defaultClassNames.dropdowns
        ),
        dropdown_root: cn(
          "relative rounded-(--cell-radius)",
          defaultClassNames.dropdown_root
        ),
        dropdown: cn(
          "absolute inset-0 bg-popover opacity-0",
          defaultClassNames.dropdown
        ),
        caption_label: cn(
          "font-medium select-none",
          captionLayout === "label"
            ? "text-sm"
            : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
          defaultClassNames.caption_label
        ),
        table: "w-full border-collapse",
        weekdays: cn("flex", defaultClassNames.weekdays),
        weekday: cn(
          "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
          defaultClassNames.weekday
        ),
        week: cn("mt-2 flex w-full", defaultClassNames.week),
        week_number_header: cn(
          "w-(--cell-size) select-none",
          defaultClassNames.week_number_header
        ),
        week_number: cn(
          "text-[0.8rem] text-muted-foreground select-none",
          defaultClassNames.week_number
        ),
        day: cn(
          "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
          props.showWeekNumber
            ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
            : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
          defaultClassNames.day
        ),
        range_start: cn(
          "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
          defaultClassNames.range_start
        ),
        range_middle: cn("rounded-none", defaultClassNames.range_middle),
        range_end: cn(
          "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
          defaultClassNames.range_end
        ),
        today: cn(
          "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
          defaultClassNames.today
        ),
        outside: cn(
          "text-muted-foreground aria-selected:text-muted-foreground",
          defaultClassNames.outside
        ),
        disabled: cn(
          "text-muted-foreground opacity-50",
          defaultClassNames.disabled
        ),
        hidden: cn("invisible", defaultClassNames.hidden),
        ...classNames,
      }}
      components={{
        Root: ({ className, rootRef, ...props }) => {
          return (
            <div
              data-slot="calendar"
              ref={rootRef}
              className={cn(className)}
              {...props}
            />
          )
        },
        Chevron: ({ className, orientation, ...props }) => {
          if (orientation === "left") {
            return (
              <HugeiconsIcon icon={ArrowLeftIcon} strokeWidth={2} className={cn("size-4", className)} {...props} />
            )
          }

          if (orientation === "right") {
            return (
              <HugeiconsIcon icon={ArrowRightIcon} strokeWidth={2} className={cn("size-4", className)} {...props} />
            )
          }

          return (
            <HugeiconsIcon icon={ArrowDownIcon} strokeWidth={2} className={cn("size-4", className)} {...props} />
          )
        },
        DayButton: ({ ...props }) => (
          <CalendarDayButton locale={locale} {...props} />
        ),
        WeekNumber: ({ children, ...props }) => {
          return (
            <td {...props}>
              <div className="flex size-(--cell-size) items-center justify-center text-center">
                {children}
              </div>
            </td>
          )
        },
        ...components,
      }}
      {...props}
    />
  )
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      variant="ghost"
      size="icon"
      data-day={day.date.toLocaleDateString(locale?.code)}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
        defaultClassNames.day,
        className
      )}
      {...props}
    />
  )
}

export { Calendar, CalendarDayButton }


# Disable loading these resources for speed
EXTRA_RESOURCES = {
    "font",
    "image",
    "media",
    "beacon",
    "object",
    "imageset",
    "texttrack",
    "websocket",
    "csp_report",
    "stylesheet",
}

HARMFUL_ARGS = (
    # This will be ignored to avoid detection more and possibly avoid the popup crashing bug abuse: https://issues.chromium.org/issues/340836884
    "--enable-automation",
    "--disable-popup-blocking",
    "--disable-component-update",
    "--disable-default-apps",
    "--disable-extensions",
)

DEFAULT_ARGS = (
    # Speed up chromium browsers by default
    "--no-pings",
    "--no-first-run",
    "--disable-infobars",
    "--disable-breakpad",
    "--no-service-autorun",
    "--homepage=about:blank",
    "--password-store=basic",
    "--disable-hang-monitor",
    "--no-default-browser-check",
    "--disable-session-crashed-bubble",
    "--disable-search-engine-choice-screen",
)

STEALTH_ARGS = (
    # Explanation: https://peter.sh/experiments/chromium-command-line-switches/
    # Generally this will make the browser faster and less detectable
    # "--incognito",
    "--test-type",
    "--lang=en-US",
    "--mute-audio",
    "--disable-sync",
    "--hide-scrollbars",
    "--disable-logging",
    "--start-maximized",  # For headless check bypass
    "--enable-async-dns",
    "--accept-lang=en-US",
    "--use-mock-keychain",
    "--disable-translate",
    "--disable-voice-input",
    "--window-position=0,0",
    "--disable-wake-on-wifi",
    "--ignore-gpu-blocklist",
    "--enable-tcp-fast-open",
    "--enable-web-bluetooth",
    "--disable-cloud-import",
    "--disable-print-preview",
    "--disable-dev-shm-usage",
    # '--disable-popup-blocking',
    "--metrics-recording-only",
    "--disable-crash-reporter",
    "--disable-partial-raster",
    "--disable-gesture-typing",
    "--disable-checker-imaging",
    "--disable-prompt-on-repost",
    "--force-color-profile=srgb",
    "--font-render-hinting=none",
    "--aggressive-cache-discard",
    "--disable-cookie-encryption",
    "--disable-domain-reliability",
    "--disable-threaded-animation",
    "--disable-threaded-scrolling",
    "--enable-simple-cache-backend",
    "--disable-background-networking",
    "--enable-surface-synchronization",
    "--disable-image-animation-resync",
    "--disable-renderer-backgrounding",
    "--disable-ipc-flooding-protection",
    "--prerender-from-omnibox=disabled",
    "--safebrowsing-disable-auto-update",
    "--disable-offer-upload-credit-cards",
    "--disable-background-timer-throttling",
    "--disable-new-content-rendering-timeout",
    "--run-all-compositor-stages-before-draw",
    "--disable-client-side-phishing-detection",
    "--disable-backgrounding-occluded-windows",
    "--disable-layer-tree-host-memory-pressure",
    "--autoplay-policy=user-gesture-required",
    "--disable-offer-store-unmasked-wallet-cards",
    "--disable-blink-features=AutomationControlled",
    "--disable-component-extensions-with-background-pages",
    "--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance",
    "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4",
    "--disable-features=AudioServiceOutOfProcess,TranslateUI,BlinkGenPropertyTrees",
)


import { type ChildProcess, spawn } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const projectRoot = path.resolve(__dirname, '..', '..');
const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js');
const DEFAULT_CLI_TIMEOUT_MS = 30_000;

let buildPromise: Promise<void> | undefined;
const activeCliChildren = new Set<ChildProcess>();

interface RunCommandOptions {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
}

interface RunCLIOptions {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  input?: string;
  timeoutMs?: number;
}

export interface RunCLIResult {
  exitCode: number | null;
  signal: NodeJS.Signals | null;
  stdout: string;
  stderr: string;
  timedOut: boolean;
  command: string;
}

function runCommand(command: string, args: string[], options: RunCommandOptions = {}) {
  return new Promise<void>((resolve, reject) => {
    const child = spawn(command, args, {
      cwd: options.cwd ?? projectRoot,
      env: { ...process.env, ...options.env },
      stdio: 'inherit',
      shell: process.platform === 'win32',
    });

    child.on('error', (error) => reject(error));
    child.on('close', (code, signal) => {
      if (code === 0) {
        resolve();
      } else {
        const reason = signal ? `signal ${signal}` : `exit code ${code}`;
        reject(new Error(`Command failed (${reason}): ${command} ${args.join(' ')}`));
      }
    });
  });
}

function mergeEnv(
  ...sources: Array<NodeJS.ProcessEnv | undefined>
): NodeJS.ProcessEnv {
  const merged: NodeJS.ProcessEnv = {};

  for (const source of sources) {
    if (!source) continue;
    for (const [key, value] of Object.entries(source)) {
      if (value === undefined) continue;

      if (process.platform === 'win32') {
        const existingKey = Object.keys(merged).find(
          (candidate) => candidate.toLowerCase() === key.toLowerCase()
        );
        if (existingKey && existingKey !== key) {
          delete merged[existingKey];
        }
      }

      merged[key] = value;
    }
  }

  return merged;
}

function terminateProcessTree(child: ChildProcess): void {
  if (!child.pid || child.killed) {
    return;
  }

  if (process.platform === 'win32') {
    spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
      stdio: 'ignore',
      windowsHide: true,
    }).on('error', () => {
      child.kill('SIGKILL');
    });
    return;
  }

  try {
    process.kill(-child.pid, 'SIGKILL');
  } catch {
    child.kill('SIGKILL');
  }
}

function formatOutputTail(output: string): string {
  const lines = output.trimEnd().split(/\r?\n/);
  return lines.slice(-20).join('\n');
}

export function terminateActiveCliChildren(): void {
  for (const child of activeCliChildren) {
    terminateProcessTree(child);
  }
}

export async function ensureCliBuilt() {
  if (existsSync(cliEntry)) {
    return;
  }

  if (!buildPromise) {
    buildPromise = runCommand('pnpm', ['run', 'build']).catch((error) => {
      buildPromise = undefined;
      throw error;
    });
  }

  await buildPromise;

  if (!existsSync(cliEntry)) {
    throw new Error('CLI entry point missing after build. Expected dist/cli/index.js');
  }
}

export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise<RunCLIResult> {
  await ensureCliBuilt();

  const finalArgs = Array.isArray(args) ? args : [args];
  const invocation = [cliEntry, ...finalArgs].join(' ');

  return new Promise<RunCLIResult>((resolve, reject) => {
    const timeoutMs = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS;
    const child = spawn(process.execPath, [cliEntry, ...finalArgs], {
      cwd: options.cwd ?? projectRoot,
      env: mergeEnv(
        process.env,
        {
          OPENSPEC_TELEMETRY: '0',
          OPEN_SPEC_INTERACTIVE: '0',
        },
        options.env
      ),
      stdio: ['pipe', 'pipe', 'pipe'],
      detached: process.platform !== 'win32',
      windowsHide: true,
    });

    // Prevent child process from keeping the event loop alive
    child.unref();
    activeCliChildren.add(child);

    let stdout = '';
    let stderr = '';
    let timedOut = false;

    const timeout = setTimeout(() => {
      timedOut = true;
      terminateProcessTree(child);
    }, timeoutMs);

    child.stdout?.setEncoding('utf-8');
    child.stdout?.on('data', (chunk) => {
      stdout += chunk;
    });

    child.stderr?.setEncoding('utf-8');
    child.stderr?.on('data', (chunk) => {
      stderr += chunk;
    });

    child.on('error', (error) => {
      clearTimeout(timeout);
      activeCliChildren.delete(child);
      // Explicitly destroy streams to prevent hanging handles
      child.stdout?.destroy();
      child.stderr?.destroy();
      child.stdin?.destroy();
      reject(error);
    });

    child.on('close', (code, signal) => {
      clearTimeout(timeout);
      activeCliChildren.delete(child);
      // Explicitly destroy streams to prevent hanging handles
      child.stdout?.destroy();
      child.stderr?.destroy();
      child.stdin?.destroy();
      if (timedOut) {
        reject(
          new Error(
            [
              `CLI command timed out after ${timeoutMs}ms: node ${invocation}`,
              stderr ? `stderr tail:\n${formatOutputTail(stderr)}` : '',
              stdout ? `stdout tail:\n${formatOutputTail(stdout)}` : '',
            ]
              .filter(Boolean)
              .join('\n\n')
          )
        );
        return;
      }
      resolve({
        exitCode: code,
        signal,
        stdout,
        stderr,
        timedOut,
        command: `node ${invocation}`,
      });
    });

    if (options.input && child.stdin) {
      child.stdin.end(options.input);
    } else if (child.stdin) {
      child.stdin.end();
    }
  });
}

export const cliProjectRoot = projectRoot;


import { beforeEach, describe, expect, test, vi } from "vitest";
import { Command } from "commander";

const trackEvent = vi.fn();
const checkForUpdates = vi.fn();
const getUpgradePlan = vi.fn();
const markUpdateNotificationShown = vi.fn();
const shouldShowUpdateNotification = vi.fn();
const shouldSkipUpdateNotifier = vi.fn();
const confirm = vi.fn();
const spawn = vi.fn();

vi.mock("../utils/tracking.js", () => ({
  trackEvent: (...args: unknown[]) => trackEvent(...args),
}));

vi.mock("../utils/update-check.js", () => ({
  checkForUpdates: (...args: unknown[]) => checkForUpdates(...args),
  getUpgradePlan: (...args: unknown[]) => getUpgradePlan(...args),
  markUpdateNotificationShown: (...args: unknown[]) => markUpdateNotificationShown(...args),
  shouldShowUpdateNotification: (...args: unknown[]) => shouldShowUpdateNotification(...args),
  shouldSkipUpdateNotifier: (...args: unknown[]) => shouldSkipUpdateNotifier(...args),
}));

vi.mock("@inquirer/prompts", () => ({
  confirm: (...args: unknown[]) => confirm(...args),
}));

vi.mock("child_process", () => ({
  spawn: (...args: unknown[]) => spawn(...args),
}));

import { maybeShowUpgradeNotice, registerUpgradeCommand } from "../commands/upgrade.js";

let logOutput: string[];
const ANSI_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;

function stripAnsi(text: string): string {
  return text.replace(ANSI_PATTERN, "");
}

async function runCommand(...args: string[]): Promise<void> {
  const program = new Command();
  program.exitOverride();
  registerUpgradeCommand(program);
  await program.parseAsync(["node", "test", ...args]);
}

beforeEach(() => {
  vi.clearAllMocks();
  logOutput = [];
  shouldShowUpdateNotification.mockResolvedValue(true);
  shouldSkipUpdateNotifier.mockReturnValue(false);
  vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
    logOutput.push(args.join(" "));
  });
  spawn.mockReturnValue({
    on: (event: string, handler: (value?: number) => void) => {
      if (event === "close") handler(0);
      return undefined;
    },
  });
});

function plainLogOutput(): string[] {
  return logOutput.map(stripAnsi);
}

describe("upgrade command", () => {
  test("reports when ctx7 is already up to date", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.13",
      updateAvailable: false,
      installMethod: "npm-global",
      upgradePlan: {
        displayCommand: "npm install -g ctx7@latest",
      },
    });

    await runCommand("upgrade");

    expect(plainLogOutput().some((line) => line.includes("ctx7 is up to date"))).toBe(true);
    expect(trackEvent).toHaveBeenCalledWith("command", { name: "upgrade" });
  });

  test("prints upgrade instructions in check mode", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.99",
      updateAvailable: true,
      installMethod: "npm-global",
      upgradePlan: {
        displayCommand: "npm install -g ctx7@latest",
        canRun: true,
        needsExplicitVersion: false,
      },
    });

    await runCommand("upgrade", "--check");

    expect(plainLogOutput().some((line) => line.includes("Update available"))).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("npm install -g ctx7@latest"))).toBe(true);
    expect(spawn).not.toHaveBeenCalled();
  });

  test("explains ephemeral runners instead of trying to self-upgrade", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.99",
      updateAvailable: true,
      installMethod: "npx",
      upgradePlan: {
        displayCommand: "npx ctx7@latest <command>",
        canRun: false,
        needsExplicitVersion: true,
      },
    });

    await runCommand("upgrade");

    expect(plainLogOutput().some((line) => line.includes("ephemeral runner"))).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("npx ctx7@latest <command>"))).toBe(true);
    expect(spawn).not.toHaveBeenCalled();
  });

  test("runs the upgrade command with --yes when possible", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.99",
      updateAvailable: true,
      installMethod: "npm-global",
      upgradePlan: {
        command: "npm",
        args: ["install", "-g", "ctx7@latest"],
        displayCommand: "npm install -g ctx7@latest",
        canRun: true,
        needsExplicitVersion: false,
      },
    });

    await runCommand("upgrade", "--yes");

    expect(spawn).toHaveBeenCalledWith(
      "npm",
      ["install", "-g", "ctx7@latest"],
      expect.objectContaining({ stdio: "inherit" })
    );
  });

  test("falls back to getUpgradePlan when update check fails", async () => {
    checkForUpdates.mockResolvedValue(null);
    getUpgradePlan.mockReturnValue({
      displayCommand: "npm install -g ctx7@latest",
    });

    await runCommand("upgrade", "--check");

    expect(plainLogOutput().some((line) => line.includes("Couldn't check for updates"))).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("npm install -g ctx7@latest"))).toBe(true);
  });

  test("shows retry guidance when the upgrade command fails", async () => {
    spawn.mockReturnValue({
      on: (event: string, handler: (value?: number) => void) => {
        if (event === "close") handler(243);
        return undefined;
      },
    });
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.99",
      updateAvailable: true,
      installMethod: "npm-global",
      upgradePlan: {
        command: "npm",
        args: ["install", "-g", "ctx7@latest"],
        displayCommand: "npm install -g ctx7@latest",
        canRun: true,
        needsExplicitVersion: false,
        installMethod: "npm-global",
      },
    });

    await runCommand("upgrade", "--yes");

    expect(
      plainLogOutput().some((line) => line.includes("Upgrade command exited with code 243"))
    ).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("Try rerunning:"))).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("permissions"))).toBe(true);
  });

  test("shows permissions guidance when install method is unknown but command is global npm", async () => {
    spawn.mockReturnValue({
      on: (event: string, handler: (value?: number) => void) => {
        if (event === "close") handler(243);
        return undefined;
      },
    });
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.13",
      latestVersion: "0.3.99",
      updateAvailable: true,
      installMethod: "unknown",
      upgradePlan: {
        command: "npm",
        args: ["install", "-g", "ctx7@latest"],
        displayCommand: "npm install -g ctx7@latest",
        canRun: false,
        needsExplicitVersion: false,
        installMethod: "unknown",
      },
    });

    await runCommand("upgrade", "--yes");

    expect(spawn).not.toHaveBeenCalled();
    expect(plainLogOutput().some((line) => line.includes("Run npm install -g ctx7@latest"))).toBe(
      true
    );
  });
});

describe("pre-command upgrade notice", () => {
  test("shows a non-blocking notice for upgradeable installs", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.11",
      latestVersion: "0.3.13",
      updateAvailable: true,
      upgradePlan: {
        command: "npm",
        args: ["install", "-g", "ctx7@latest"],
        displayCommand: "npm install -g ctx7@latest",
        canRun: true,
        needsExplicitVersion: false,
      },
    });
    await maybeShowUpgradeNotice({
      actionName: "library",
      argv: ["node", "ctx7", "library", "react"],
      isInteractive: true,
    });

    expect(plainLogOutput().some((line) => line.includes("Update available:"))).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("Run ctx7 upgrade to update now"))).toBe(
      true
    );
    expect(plainLogOutput().some((line) => line.includes("npm install -g ctx7@latest"))).toBe(true);
    expect(confirm).not.toHaveBeenCalled();
    expect(spawn).not.toHaveBeenCalled();
    expect(markUpdateNotificationShown).toHaveBeenCalledWith("0.3.13");
  });

  test("shows guidance-only notice for unknown installs", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.11",
      latestVersion: "0.3.13",
      updateAvailable: true,
      upgradePlan: {
        command: "npm",
        args: ["install", "-g", "ctx7@latest"],
        displayCommand: "npm install -g ctx7@latest",
        canRun: false,
        needsExplicitVersion: false,
      },
    });

    await maybeShowUpgradeNotice({
      actionName: "library",
      argv: ["node", "ctx7", "library", "react"],
      isInteractive: true,
    });

    expect(
      plainLogOutput().some((line) => line.includes("Run ctx7 upgrade for update steps"))
    ).toBe(true);
    expect(plainLogOutput().some((line) => line.includes("npm install -g ctx7@latest"))).toBe(true);
    expect(spawn).not.toHaveBeenCalled();
    expect(markUpdateNotificationShown).toHaveBeenCalledWith("0.3.13");
  });

  test("shows runner-specific guidance for ephemeral installs", async () => {
    checkForUpdates.mockResolvedValue({
      currentVersion: "0.3.11",
      latestVersion: "0.3.13",
      updateAvailable: true,
      upgradePlan: {
        displayCommand: "npx ctx7@latest <command>",
        canRun: false,
        needsExplicitVersion: true,
      },
    });

    await maybeShowUpgradeNotice({
      actionName: "library",
      argv: ["node", "ctx7", "library", "react"],
      isInteractive: true,
    });

    expect(plainLogOutput().some((line) => line.includes("Use npx ctx7@latest <command>"))).toBe(
      true
    );
    expect(plainLogOutput().some((line) => line.includes("ctx7 upgrade"))).toBe(false);
    expect(confirm).not.toHaveBeenCalled();
    expect(spawn).not.toHaveBeenCalled();
    expect(markUpdateNotificationShown).toHaveBeenCalledWith("0.3.13");
  });

  test("skips notice for upgrade command", async () => {
    await maybeShowUpgradeNotice({
      actionName: "upgrade",
      argv: ["node", "ctx7", "upgrade"],
      isInteractive: true,
    });

    expect(checkForUpdates).not.toHaveBeenCalled();
  });
});