HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/.codex/.tmp/plugins/plugins/mixpanel-headless/skills/mixpanelyst/scripts/help.py
#!/usr/bin/env python3
"""Programmatic API documentation lookup for mixpanel_headless.

Usage:
    python help.py Workspace                    # List all Workspace methods
    python help.py Workspace.segmentation       # Method signature + docstring
    python help.py SegmentationResult           # Type/class documentation
    python help.py FeatureFlagStatus            # Enum members + values
    python help.py types                        # List all public types
    python help.py exceptions                   # List all exceptions
    python help.py search <term>                # Search all names for a term
"""

import contextlib
import dataclasses
import difflib
import enum
import importlib
import inspect
import re
import sys
from pathlib import Path
from typing import Any

try:
    from pydantic_core import PydanticUndefined
except ImportError:
    PydanticUndefined = None  # type: ignore[assignment,misc]


def get_obj(path: str) -> Any:
    """Navigate a dotted path from mixpanel_headless to find the target object."""
    mod = importlib.import_module("mixpanel_headless")
    parts = path.split(".")
    obj: Any = mod
    for part in parts:
        obj = getattr(obj, part)
    return obj


def format_type(annotation: Any) -> str:
    """Format a type annotation for clean display.

    Args:
        annotation: A type annotation (class, generic, union, etc.).

    Returns:
        Human-readable type string with noise removed.
    """
    if annotation is None or annotation is type(None):
        return "None"
    if hasattr(annotation, "__name__") and not hasattr(annotation, "__args__"):
        return annotation.__name__
    s = str(annotation)
    s = s.replace("typing.", "").replace("typing_extensions.", "")
    s = s.replace("<class '", "").replace("'>", "")
    s = s.replace("NoneType", "None")
    return s


def _enum_values_inline(annotation: Any) -> str:
    """Return inline enum member names if annotation contains an Enum type.

    Args:
        annotation: A type annotation to inspect.

    Returns:
        String like ' [ENABLED | DISABLED | ARCHIVED]' or empty string.
    """
    candidates: list[type] = []
    if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
        candidates.append(annotation)
    args = getattr(annotation, "__args__", None)
    if args:
        for arg in args:
            if isinstance(arg, type) and issubclass(arg, enum.Enum):
                candidates.append(arg)
    if not candidates:
        return ""
    all_names: list[str] = []
    for cls in candidates:
        all_names.extend(m.name for m in cls)
    return f" [{' | '.join(all_names)}]"


def format_signature(obj: Any, name: str) -> str:
    """Format a callable's full signature with type annotations."""
    try:
        sig = inspect.signature(obj)
        params = []
        for pname, param in sig.parameters.items():
            if pname == "self":
                continue
            annotation = ""
            if param.annotation != inspect.Parameter.empty:
                annotation = f": {format_type(param.annotation)}"
            default = ""
            if param.default != inspect.Parameter.empty:
                default = f" = {param.default!r}"
            params.append(f"    {pname}{annotation}{default}")

        ret = ""
        if sig.return_annotation != inspect.Signature.empty:
            ret = f" -> {format_type(sig.return_annotation)}"

        param_str = ",\n".join(params)
        if params:
            return f"{name}(\n{param_str}\n){ret}"
        return f"{name}(){ret}"
    except (ValueError, TypeError):
        return f"{name}(...)"


def show_enum(obj: type, name: str) -> None:
    """Display enum members with their values.

    Args:
        obj: An Enum subclass.
        name: Display name for the enum.
    """
    members = list(obj)  # type: ignore[arg-type]
    print(f"# {name} \u2014 {len(members)} members\n")
    max_name_len = max((len(m.name) for m in members), default=0)
    for member in members:
        print(f"  {member.name:<{max_name_len}}  = {member.value!r}")
    doc = inspect.getdoc(obj)
    if doc:
        print(f"\n{doc}")


def list_members(obj: Any, name: str) -> None:
    """List public methods and properties of an object."""
    if isinstance(obj, type) and issubclass(obj, enum.Enum):
        show_enum(obj, name)
        return

    members: list[tuple[str, str]] = []
    for attr_name in sorted(dir(obj)):
        if attr_name.startswith("_"):
            continue
        try:
            attr = getattr(obj, attr_name, None)
        except Exception:
            continue

        if attr is None:
            continue

        doc = inspect.getdoc(attr) or ""
        first_line = doc.split("\n")[0] if doc else "(no description)"

        if isinstance(attr, property):
            members.append((attr_name, f"[property] {first_line}"))
        elif callable(attr):
            members.append((attr_name, first_line))

    print(f"# {name} \u2014 {len(members)} public members\n")
    for mname, desc in members:
        print(f"  {mname:42s} {desc}")


def list_types() -> None:
    """List all public types exported by mixpanel_headless."""
    mod = importlib.import_module("mixpanel_headless")
    types_list: list[tuple[str, str]] = []
    for name in sorted(dir(mod)):
        if name.startswith("_"):
            continue
        obj = getattr(mod, name)
        if (
            isinstance(obj, type)
            and name != "Workspace"
            and not issubclass(obj, Exception)
        ):
            doc = inspect.getdoc(obj) or ""
            first_line = doc.split("\n")[0] if doc else ""
            types_list.append((name, first_line))

    print(f"# mixpanel_headless \u2014 {len(types_list)} public types\n")
    for name, desc in types_list:
        print(f"  {name:45s} {desc}")


def list_exceptions() -> None:
    """List all exception types from mixpanel_headless."""
    mod = importlib.import_module("mixpanel_headless")
    excs: list[tuple[str, str]] = []
    for name in sorted(dir(mod)):
        obj = getattr(mod, name)
        if isinstance(obj, type) and issubclass(obj, Exception):
            doc = inspect.getdoc(obj) or ""
            first_line = doc.split("\n")[0] if doc else ""
            excs.append((name, first_line))

    print(f"# mixpanel_headless \u2014 {len(excs)} exception types\n")
    for name, desc in excs:
        print(f"  {name:35s} {desc}")


def show_fields(obj: type, indent: str = "") -> None:
    """Show fields for dataclasses or Pydantic models.

    Args:
        obj: A dataclass or Pydantic BaseModel class.
        indent: Prefix for indentation.
    """
    if hasattr(obj, "__dataclass_fields__"):
        print(f"\n{indent}Fields:")
        for fname, field in obj.__dataclass_fields__.items():
            ftype = format_type(field.type) if hasattr(field, "type") else "?"
            default = ""
            try:
                if field.default is not dataclasses.MISSING:
                    default = f" = {field.default!r}"
                elif field.default_factory is not dataclasses.MISSING:  # type: ignore[misc]
                    factory_name = getattr(
                        field.default_factory, "__name__", repr(field.default_factory)
                    )
                    default = f" = <factory {factory_name}>"
            except Exception:
                pass
            print(f"{indent}  {fname}: {ftype}{default}")
    elif hasattr(obj, "model_fields"):
        # Resolve alias generator from model config
        config = getattr(obj, "model_config", {})
        alias_gen = config.get("alias_generator")

        print(f"\n{indent}Fields:")
        for fname, finfo in obj.model_fields.items():
            type_str = format_type(finfo.annotation)
            enum_inline = _enum_values_inline(finfo.annotation)

            # Default value handling (fix PydanticUndefined leak)
            if finfo.is_required():
                default = " (required)"
            elif PydanticUndefined is not None and finfo.default is PydanticUndefined:
                if finfo.default_factory is not None:
                    factory_name = getattr(finfo.default_factory, "__name__", "factory")
                    default = f" = <factory {factory_name}>"
                else:
                    default = " (required)"
            elif finfo.default is None:
                default = ""
            else:
                default = f" = {finfo.default!r}"

            # Field constraints from metadata
            constraints = ""
            if finfo.metadata:
                constraint_parts: list[str] = []
                for meta in finfo.metadata:
                    for attr in (
                        "max_length",
                        "min_length",
                        "ge",
                        "le",
                        "gt",
                        "lt",
                        "multiple_of",
                        "pattern",
                    ):
                        val = getattr(meta, attr, None)
                        if val is not None:
                            constraint_parts.append(f"{attr}={val}")
                if constraint_parts:
                    constraints = f" ({', '.join(constraint_parts)})"

            # Alias resolution
            alias_info = ""
            resolved_alias = finfo.alias
            if resolved_alias is None and alias_gen and callable(alias_gen):
                with contextlib.suppress(TypeError, ValueError, AttributeError):
                    resolved_alias = alias_gen(fname)
            if resolved_alias and resolved_alias != fname:
                alias_info = f' (json: "{resolved_alias}")'

            print(
                f"{indent}  {fname}: "
                f"{type_str}{enum_inline}{default}{constraints}{alias_info}"
            )


def show_model_config(obj: type) -> None:
    """Show non-default Pydantic model config values.

    Args:
        obj: A Pydantic BaseModel subclass.
    """
    config = getattr(obj, "model_config", None)
    if not config:
        return
    defaults = {
        "frozen": False,
        "extra": "ignore",
        "populate_by_name": False,
    }
    parts: list[str] = []
    for key, default_val in defaults.items():
        val = config.get(key)
        if val is not None and val != default_val:
            parts.append(f"{key}={val!r}")
    ag = config.get("alias_generator")
    if ag is not None:
        gen_name = getattr(ag, "__name__", str(ag))
        parts.append(f"alias_generator={gen_name}")
    if parts:
        print(f"  Config: {', '.join(parts)}")


def show_related(type_name: str) -> None:
    """Show Workspace methods that reference the given type.

    Args:
        type_name: Simple class name to search for in method signatures.
    """
    mod = importlib.import_module("mixpanel_headless")
    ws_cls = getattr(mod, "Workspace", None)
    if ws_cls is None:
        return

    matches: list[str] = []
    for attr_name in sorted(dir(ws_cls)):
        if attr_name.startswith("_"):
            continue
        attr = getattr(ws_cls, attr_name, None)
        if not callable(attr) or isinstance(attr, property):
            continue
        try:
            sig = inspect.signature(attr)
        except (ValueError, TypeError):
            continue

        if type_name not in str(sig):
            continue

        ret = ""
        if sig.return_annotation != inspect.Signature.empty:
            ret = f" -> {format_type(sig.return_annotation)}"
        param_parts: list[str] = []
        for pname, param in sig.parameters.items():
            if pname == "self":
                continue
            if param.annotation != inspect.Parameter.empty:
                param_parts.append(f"{pname}: {format_type(param.annotation)}")
            else:
                param_parts.append(pname)
        params_str = ", ".join(param_parts)
        matches.append(f"  {attr_name}({params_str}){ret}")

    if matches:
        print(f"\nUsed by Workspace ({len(matches)} methods):")
        for m in matches:
            print(m)


def _show_referenced_types(obj: Any) -> None:
    """Show types from mixpanel_headless referenced in a callable's signature.

    Scans the signature string for type names exported by the module
    and prints matching types with their one-line descriptions.

    Args:
        obj: A callable (method or function) to inspect.
    """
    try:
        sig = inspect.signature(obj)
    except (ValueError, TypeError):
        return

    sig_text = str(sig)
    mod = importlib.import_module("mixpanel_headless")
    found: list[tuple[str, str]] = []

    for name in sorted(dir(mod)):
        if name.startswith("_") or name == "Workspace" or len(name) < 4:
            continue
        val = getattr(mod, name)
        if not isinstance(val, type):
            continue
        if re.search(rf"\b{re.escape(name)}\b", sig_text):
            doc = inspect.getdoc(val) or ""
            first_line = doc.split("\n")[0] if doc else ""
            found.append((name, first_line))

    if found:
        print(f"\nReferenced types ({len(found)}):")
        for name, desc in found:
            print(f"  {name:42s} {desc}")


# Entity nouns for "See also" grouping — ordered so each substring
# appears after strings that contain it (e.g. "feature_flag" before "flag").
_ENTITY_NOUNS = [
    "custom_property",
    "custom_event",
    "lookup_table",
    "drop_filter",
    "feature_flag",
    "annotation_tag",
    "lexicon_tag",
    "blueprint",
    "dashboard",
    "bookmark",
    "experiment",
    "annotation",
    "webhook",
    "cohort",
    "alert",
    "schema",
    "flag",
]


def _show_see_also(path: str) -> None:
    """Show related methods for the same entity (CRUD siblings).

    Extracts the entity noun from the method name and finds other
    Workspace methods operating on the same entity.

    Args:
        path: Dotted path like ``Workspace.create_dashboard``.
    """
    parts = path.rsplit(".", 1)
    if len(parts) != 2 or parts[0] != "Workspace":
        return
    method_name = parts[1]

    matched = None
    for entity in _ENTITY_NOUNS:
        if entity in method_name:
            matched = entity
            break

    if not matched:
        return

    mod = importlib.import_module("mixpanel_headless")
    ws_cls = getattr(mod, "Workspace", None)
    if ws_cls is None:
        return

    siblings = [
        name
        for name in sorted(dir(ws_cls))
        if not name.startswith("_") and name != method_name and matched in name
    ]

    if siblings:
        print(f"\nSee also: {', '.join(siblings)}")


def _doc_summary(doc: str) -> str:
    """Extract the description portion of a docstring.

    Returns everything before the first keyword section
    (Args:, Returns:, Raises:, Example:, etc.).

    Args:
        doc: Full docstring text.

    Returns:
        Description text only, with keyword sections stripped.
    """
    if not doc:
        return ""
    lines = doc.split("\n")
    summary_lines: list[str] = []
    for line in lines:
        if line.strip().startswith(
            (
                "Args:",
                "Returns:",
                "Raises:",
                "Example:",
                "Examples:",
                "Yields:",
                "Note:",
                "Notes:",
                "Attributes:",
            )
        ):
            break
        summary_lines.append(line)
    return "\n".join(summary_lines)


def _get_all_names() -> list[tuple[str, str]]:
    """Collect all public names for fuzzy matching.

    Returns:
        List of (name, first_line_docstring) tuples covering
        module-level exports and Workspace members.
    """
    mod = importlib.import_module("mixpanel_headless")
    names: list[tuple[str, str]] = []

    for name in sorted(dir(mod)):
        if name.startswith("_"):
            continue
        obj = getattr(mod, name)
        doc = inspect.getdoc(obj) or ""
        first_line = doc.split("\n")[0] if doc else ""
        names.append((name, first_line))

    ws_cls = getattr(mod, "Workspace", None)
    if ws_cls:
        for attr_name in sorted(dir(ws_cls)):
            if attr_name.startswith("_"):
                continue
            attr = getattr(ws_cls, attr_name, None)
            if attr is None:
                continue
            doc = inspect.getdoc(attr) or ""
            first_line = doc.split("\n")[0] if doc else ""
            names.append((f"Workspace.{attr_name}", first_line))

    return names


def _suggest_similar(
    query: str,
    candidates: list[tuple[str, str]] | None = None,
    prefix: str = "",
) -> bool:
    """Suggest similar names when an exact lookup fails.

    Uses ``difflib.get_close_matches`` for edit-distance fuzzy matching.

    Args:
        query: The failed lookup string.
        candidates: Optional (name, description) pairs. If ``None``,
            uses all public names from ``_get_all_names()``.
        prefix: Optional prefix to prepend to displayed names
            (e.g. ``"Workspace."`` for dotted lookups).

    Returns:
        ``True`` if suggestions were printed, ``False`` otherwise.
    """
    if candidates is None:
        candidates = _get_all_names()

    names = [name for name, _ in candidates]
    matches = difflib.get_close_matches(query, names, n=5, cutoff=0.5)

    if not matches:
        return False

    print("\nDid you mean?")
    name_to_desc = dict(candidates)
    for match in matches:
        display = f"{prefix}{match}" if prefix else match
        desc = name_to_desc.get(match, "")
        print(f"  {display:42s} {desc}")
    return True


# Reference hints: ordered most-specific-first so e.g. "query_funnel" matches
# funnels before "query" matches insights.  Each entry is
# (trigger_keywords, hosted_docs_path, one-line description).
_DOCS_BASE = "https://mixpanel.github.io/mixpanel-headless"

_REFERENCE_HINTS: list[tuple[frozenset[str], str, str]] = [
    (
        frozenset({"query_user", "build_user_params", "UserQueryResult"}),
        "guide/query-users/index.md",
        "User profile queries — filtering, sorting, aggregate counts",
    ),
    (
        frozenset(
            {
                "query_flow",
                "build_flow_params",
                "query_saved_flows",
                "FlowQueryResult",
                "FlowStep",
                "FlowTreeNode",
                "FlowNodeType",
                "FlowAnchorType",
                "FlowCountType",
                "FlowChartType",
            }
        ),
        "guide/query-flows/index.md",
        "FlowStep, NetworkX graph, anytree trees, modes",
    ),
    (
        frozenset(
            {
                "query_retention",
                "build_retention_params",
                "RetentionQueryResult",
                "RetentionEvent",
                "RetentionMathType",
                "RetentionAlignment",
                "RetentionMode",
            }
        ),
        "guide/query-retention/index.md",
        "RetentionEvent, alignment, custom buckets",
    ),
    (
        frozenset(
            {
                "query_funnel",
                "build_funnel_params",
                "FunnelQueryResult",
                "FunnelStep",
                "Exclusion",
                "HoldingConstant",
                "FunnelMathType",
            }
        ),
        "guide/query-funnels/index.md",
        "FunnelStep, Exclusion, HoldingConstant, conversion windows",
    ),
    (
        frozenset(
            {
                "query",
                "build_params",
                "QueryResult",
                "MathType",
                "PerUserAggregation",
                "Filter",
                "GroupBy",
                "Formula",
                "Metric",
                "CohortBreakdown",
                "CohortMetric",
                "CohortDefinition",
                "CustomPropertyRef",
                "InlineCustomProperty",
            }
        ),
        "guide/query/index.md",
        "MathType, Filter, GroupBy, Formula, validation rules",
    ),
    (
        frozenset(
            {
                "create_bookmark",
                "update_bookmark",
                "get_bookmark",
                "validate_bookmark",
                "query_saved_report",
                "CreateBookmarkParams",
                "UpdateBookmarkParams",
                "BookmarkInfo",
                "SavedReportResult",
                "SavedReportType",
            }
        ),
        "guide/entity-management/index.md",
        "bookmark params, entity management",
    ),
]

# Dashboard hints point to the dashboard-expert skill (sibling skill directory),
# not the analyst's own references/.  Checked separately because the file lives
# outside refs_dir.
_DASHBOARD_TRIGGERS = frozenset(
    {
        "create_dashboard",
        "get_dashboard",
        "update_dashboard",
        "delete_dashboard",
        "list_dashboards",
        "add_report_to_dashboard",
        "remove_report_from_dashboard",
        "favorite_dashboard",
        "unfavorite_dashboard",
        "pin_dashboard",
        "unpin_dashboard",
        "update_text_card",
        "update_report_link",
        "bulk_delete_dashboards",
        "CreateDashboardParams",
        "UpdateDashboardParams",
        "UpdateTextCardParams",
        "UpdateReportLinkParams",
        "Dashboard",
    }
)


def _show_reference_hints(query: str) -> None:
    """Print a contextual reference hint based on the help query.

    Checks each hint rule in priority order (most specific first).
    Prints the first matching hint, pointing the reader to the
    relevant hosted documentation page.

    Args:
        query: The user's help query string.
    """
    parts = query.replace(".", " ").split()
    part_set = set(parts)

    # Special case: standalone "Workspace" (no dot) → hosted API reference
    if query == "Workspace":
        print(
            "\n---\n"
            "Tip: For complete method signatures organized by domain,\n"
            f'     WebFetch(url="{_DOCS_BASE}/api/workspace/index.md")'
        )
        return

    for triggers, docs_path, description in _REFERENCE_HINTS:
        # Match if any trigger appears as a token (substring matching avoided
        # because generic triggers like "query" false-positive on compound
        # names like "query_saved_report")
        if part_set & triggers:
            print(
                f"\n---\n"
                f"Tip: For tutorials and examples ({description}),\n"
                f'     WebFetch(url="{_DOCS_BASE}/{docs_path}")'
            )
            return

    # Dashboard hints — lives in sibling skill directory
    if "dashboard" in query.lower() or (part_set & _DASHBOARD_TRIGGERS):
        skill_dir = Path(__file__).resolve().parent.parent.parent / "dashboard-expert"
        ref = skill_dir / "references" / "dashboard-reference.md"
        if ref.is_file():
            print(
                "\n---\n"
                "Tip: For dashboard analysis, creation, layout, text cards, and templates,\n"
                "     read skills/dashboard-expert/references/dashboard-reference.md\n"
                "     Design templates: skills/dashboard-expert/references/"
                "dashboard-templates.md\n"
                "     Full workflow:    skills/dashboard-expert/SKILL.md"
            )


def show_detail(obj: Any, path: str) -> None:
    """Show detailed documentation for a specific object."""
    if isinstance(obj, type) and issubclass(obj, enum.Enum):
        show_enum(obj, path)
        show_related(path.split(".")[-1])
        return

    if isinstance(obj, type):
        print(f"class {path}")
        bases = [b.__name__ for b in obj.__mro__[1:] if b.__name__ != "object"]
        if bases:
            arrow = " \u2192 "
            print(f"  Inherits: {arrow.join(bases)}")
        if hasattr(obj, "model_config"):
            show_model_config(obj)
        show_fields(obj)
    elif callable(obj):
        print(format_signature(obj, path))
    else:
        print(f"{path} = {obj!r}")

    doc = inspect.getdoc(obj)
    if doc:
        print(f"\n{doc}")
    else:
        print("\n(no docstring)")

    if isinstance(obj, type) and path.split(".")[-1] != "Workspace":
        show_related(path.split(".")[-1])

    if callable(obj) and not isinstance(obj, type):
        _show_referenced_types(obj)
        _show_see_also(path)


def search(term: str) -> None:
    """Search all public names (types, methods, properties) for a term.

    Case-insensitive substring match across module-level exports and
    Workspace methods/properties. Shows matched name + first-line docstring.

    Args:
        term: Substring to search for (case-insensitive).
    """
    mod = importlib.import_module("mixpanel_headless")
    needle = term.lower()
    results: list[tuple[str, str, str]] = []  # (category, name, description)

    # Search module-level exports (types, functions, enums)
    for name in sorted(dir(mod)):
        if name.startswith("_"):
            continue
        if needle not in name.lower():
            continue
        obj = getattr(mod, name)
        doc = inspect.getdoc(obj) or ""
        first_line = doc.split("\n")[0] if doc else ""
        if isinstance(obj, type) and issubclass(obj, Exception):
            results.append(("exception", name, first_line))
        elif isinstance(obj, type) and issubclass(obj, enum.Enum):
            results.append(("enum", name, first_line))
        elif isinstance(obj, type):
            results.append(("type", name, first_line))
        elif callable(obj):
            results.append(("function", name, first_line))

    # Search Workspace methods and properties
    ws_cls = getattr(mod, "Workspace", None)
    if ws_cls is not None:
        for attr_name in sorted(dir(ws_cls)):
            if attr_name.startswith("_"):
                continue
            if needle not in attr_name.lower():
                continue
            attr = getattr(ws_cls, attr_name, None)
            if attr is None:
                continue
            doc = inspect.getdoc(attr) or ""
            first_line = doc.split("\n")[0] if doc else ""
            qualified = f"Workspace.{attr_name}"
            if isinstance(attr, property):
                results.append(("property", qualified, first_line))
            elif callable(attr):
                results.append(("method", qualified, first_line))

    # Extend search to docstring content (avoid duplicates)
    matched_names = {r[1] for r in results}

    for name in sorted(dir(mod)):
        if name.startswith("_") or name in matched_names:
            continue
        obj = getattr(mod, name)
        doc = inspect.getdoc(obj) or ""
        first_line = doc.split("\n")[0] if doc else ""
        if needle not in _doc_summary(doc).lower():
            continue
        if isinstance(obj, type) and issubclass(obj, Exception):
            results.append(("exception", name, first_line))
        elif isinstance(obj, type) and issubclass(obj, enum.Enum):
            results.append(("enum", name, first_line))
        elif isinstance(obj, type):
            results.append(("type", name, first_line))
        elif callable(obj):
            results.append(("function", name, first_line))
        matched_names.add(name)

    if ws_cls is not None:
        for attr_name in sorted(dir(ws_cls)):
            if attr_name.startswith("_"):
                continue
            qualified = f"Workspace.{attr_name}"
            if qualified in matched_names:
                continue
            attr = getattr(ws_cls, attr_name, None)
            if attr is None:
                continue
            doc = inspect.getdoc(attr) or ""
            first_line = doc.split("\n")[0] if doc else ""
            if needle not in _doc_summary(doc).lower():
                continue
            if isinstance(attr, property):
                results.append(("property", qualified, first_line))
            elif callable(attr):
                results.append(("method", qualified, first_line))
            matched_names.add(qualified)

    # Search enum member names and values
    for name in sorted(dir(mod)):
        if name.startswith("_"):
            continue
        obj = getattr(mod, name)
        if not (isinstance(obj, type) and issubclass(obj, enum.Enum)):
            continue
        for member in obj:
            if needle in member.name.lower() or needle in str(member.value).lower():
                qualified = f"{name}.{member.name}"
                if qualified not in matched_names:
                    results.append(("member", qualified, f"= {member.value!r}"))
                    matched_names.add(qualified)

    if not results:
        print(f'No matches for "{term}"')
        _suggest_similar(term)
        return

    print(f'# Search: "{term}" — {len(results)} matches\n')
    max_name = max(len(r[1]) for r in results)
    for category, name, desc in results:
        print(f"  [{category:9s}] {name:<{max_name}}  {desc}")


def main() -> None:
    """Entry point for the help script."""
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(0)

    query = sys.argv[1]

    try:
        importlib.import_module("mixpanel_headless")
    except ImportError:
        print("Error: mixpanel_headless is not installed.")
        print("Run /mixpanel-headless:setup to install it.")
        sys.exit(1)

    if query == "search":
        if len(sys.argv) < 3:
            print("Usage: help.py search <term>")
            print("Example: help.py search cohort")
            sys.exit(1)
        search(sys.argv[2])
        return
    elif query == "types":
        list_types()
    elif query == "exceptions":
        list_exceptions()
    elif "." not in query:
        try:
            obj = get_obj(query)
            if isinstance(obj, type) and issubclass(obj, enum.Enum):
                show_enum(obj, query)
                show_related(query)
            elif isinstance(obj, type):
                if hasattr(obj, "model_fields") or hasattr(obj, "__dataclass_fields__"):
                    show_detail(obj, query)
                else:
                    list_members(obj, query)
            else:
                show_detail(obj, query)
        except AttributeError:
            print(f"Error: '{query}' not found in mixpanel_headless")
            if not _suggest_similar(query):
                print(
                    "Try: Workspace, types, exceptions, or a dotted path"
                    " like Workspace.segmentation"
                )
            sys.exit(1)
    else:
        try:
            obj = get_obj(query)
            show_detail(obj, query)
        except AttributeError as e:
            print(f"Error: {e}")
            parts = query.rsplit(".", 1)
            if len(parts) == 2:
                try:
                    parent = get_obj(parts[0])
                    members = []
                    for attr_name in sorted(dir(parent)):
                        if attr_name.startswith("_"):
                            continue
                        attr = getattr(parent, attr_name, None)
                        if attr is None:
                            continue
                        doc = inspect.getdoc(attr) or ""
                        first_line = doc.split("\n")[0] if doc else ""
                        members.append((attr_name, first_line))
                    if not _suggest_similar(parts[1], members, prefix=f"{parts[0]}."):
                        print(f"\nAvailable on {parts[0]}:")
                        list_members(parent, parts[0])
                except AttributeError:
                    pass
            else:
                _suggest_similar(query)
            sys.exit(1)

    if query not in ("types", "exceptions"):
        _show_reference_hints(query)


if __name__ == "__main__":
    main()