Skip to content
Data
Hook

Hooks

What databricks-agent-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
databricks-agent-skills
252150 skills4 commands3 hooks
Install
> /plugin marketplace add databricks/databricks-agent-skills
> /plugin install databricks@databricks-agent-skills

Ships with databricks-agent-skills. Installing the plugin gets these hooks.

What fires, and when

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • python3 "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-router.py" || python "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-router.py" || true

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • Matchesstartup|clear|compactpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-context.py" || python "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-context.py" || true

PostToolUse

  • MatchesBashpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-auth-helper.py" || python "${CLAUDE_PLUGIN_ROOT}/hooks/databricks-auth-helper.py" || true
Read hooks/hooks.json

In the plugin's words

How databricks-agent-skills describes its own hook set.

Databricks plugin hooks: route Databricks prompts into the skills, prime session context, and hint on auth failures.

Where it lives

  • hooks/databricks-auth-helper.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook: suggest an auth fix when a `databricks` command fails auth.
    
    Watches shell tool results. When the command actually invoked the `databricks`
    CLI (as a segment executable, not merely "databricks" appearing in a repo
    path, URL, or argument) and the output looks like an authentication failure
    (missing credentials, expired or invalid token, OAuth refresh failure), it
    injects one line of additional context pointing at the doctor command and
    `databricks auth login`. Everything else passes through silently.
    
    No gating: this never blocks or rewrites a tool call, it only adds context
    after the fact.
    
    Contract (Claude Code PostToolUse hook, matcher: Bash; Cursor postToolUse,
    matcher: Shell, with `--platform cursor`):
      stdin : JSON with tool_name + the command and result. Claude/Codex carry
              tool_input.command and tool_response; Cursor carries tool_input /
              tool_output, either of which may itself be a JSON-encoded string.
      stdout: platform-shaped JSON carrying the hint, or "{}".
              Claude/Codex -> hookSpecificOutput.additionalContext
              Cursor       -> additional_context
      Fail-open: on ANY error print "{}" and exit 0.
    """
    import json
    import re
    import sys
    
    # `databricks` must be the executable of one of the command's shell segments,
    # not a substring anywhere in the command line: `gh pr view --repo
    # databricks/cli`, URLs, and file paths mention databricks without invoking
    # the CLI, and their output can legitimately quote auth-failure phrases (a PR
    # body, this hook's own source). Segments are split on shell connectors and
    # command-substitution openers; each segment's executable is its first token
    # after env assignments, common wrappers, and wrapper flags. Path-prefixed
    # invocations (`/usr/local/bin/databricks`) count; `databricks-test` does not.
    _SEGMENT_SPLIT_RE = re.compile(r"&&|\|\||\$\(|[;|&\n`(]")
    _ENV_ASSIGNMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
    _WRAPPERS = frozenset({"sudo", "env", "command", "exec", "time", "nohup", "xargs"})
    
    # Shell-running tool names across the harnesses that consume this hook:
    # Claude Code uses Bash, Cursor uses Shell, Codex documents Bash (with
    # shell/unified_exec variants in the wild), VS Code Copilot uses
    # run_in_terminal. The real filter is _invokes_databricks_cli below; this
    # gate only keeps non-shell tools (file reads, edits) from being scanned.
    _SHELL_TOOL_RE = re.compile(r"(?i)^(bash|shell|local_shell|unified_exec|run_in_terminal)$")
    
    # Per-platform names for the setup/doctor commands referenced in the hint
    # (Claude Code namespaces plugin commands as /databricks:<name>; Cursor has a
    # flat / menu, so the Cursor plugin ships them as /databricks-<name>).
    PLATFORMS = {
        "claude": {"setup_cmd": "/databricks:setup", "doctor_cmd": "/databricks:doctor"},
        "cursor": {"setup_cmd": "/databricks-setup", "doctor_cmd": "/databricks-doctor"},
    }
    
    
    def _platform_from_argv(argv):
        """Platform from `--platform <name>` / `--platform=<name>`, default claude.
    
        Unknown values fall back to claude rather than erroring: a wiring typo must
        degrade to a working hook, never a broken one.
        """
        for i, arg in enumerate(argv):
            if arg == "--platform" and i + 1 < len(argv):
                value = argv[i + 1]
            elif arg.startswith("--platform="):
                value = arg.split("=", 1)[1]
            else:
                continue
            return value if value in PLATFORMS else "claude"
        return "claude"
    
    
    def _segment_executable(tokens):
        """First token that is not an env assignment, wrapper, or wrapper flag."""
        after_wrapper = False
        for token in tokens:
            if _ENV_ASSIGNMENT_RE.match(token):
                continue
            if token in _WRAPPERS:
                after_wrapper = True
                continue
            if after_wrapper and token.startswith("-"):
                continue
            return token
        return ""
    
    
    def _invokes_databricks_cli(command):
        """True when any segment of the command runs the `databricks` executable."""
        for segment in _SEGMENT_SPLIT_RE.split(command):
            executable = _segment_executable(segment.split())
            if executable.rsplit("/", 1)[-1] == "databricks":
                return True
        return False
    
    # Phrase-shaped auth-failure signals as emitted by the CLI / Go SDK error
    # paths. Deliberately not bare status codes, so ordinary data in stdout
    # (e.g. a row containing 401) cannot trip them.
    AUTH_ERROR_PATTERNS = [
        r"cannot configure default credentials",
        r"\binvalid_grant\b",
        r"\b401 unauthorized\b",
        r"\binvalid access token\b",
        r"\btoken (?:is |has |was )?expired\b",
        r"\brefresh token (?:is |was )?(?:invalid|expired|revoked)\b",
    ]
    _AUTH_ERRORS = [re.compile(p, re.IGNORECASE) for p in AUTH_ERROR_PATTERNS]
    
    AUTH_HINT_TEMPLATE = (
        "[DATABRICKS] The `databricks` command above failed with what looks like "
        "an authentication error. Before retrying, fix auth: run "
        "`{doctor_cmd}` for a read-only diagnosis, or re-authenticate with "
        "`databricks auth login --host <workspace-url> --profile <name>` "
        "(`{setup_cmd}` walks through it). Never auto-select a profile for "
        "the user."
    )
    
    
    def _parse_maybe_json(value):
        """Return a dict from `value`, decoding it first if it's a JSON string."""
        if isinstance(value, str):
            try:
                value = json.loads(value)
            except Exception:
                return None
        return value if isinstance(value, dict) else None
    
    
    def extract_payload(data):
        """(tool_name, command, response_text) from a Claude/Codex or Cursor payload."""
        tool_name = str(data.get("tool_name", ""))
        tool_input = _parse_maybe_json(data.get("tool_input"))
        command = tool_input.get("command", "") if tool_input else ""
        if not isinstance(command, str):
            command = ""
        # Claude/Codex put the result in tool_response; Cursor in tool_output
        # (often as an already-JSON-encoded string). Serialize non-strings instead
        # of assuming their shape;
  • hooks/databricks-context.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """SessionStart hook: inject a compact Databricks context banner.
    
    Local-only and fail-open by design. It never makes a network call (so it can't
    hang, hit an MCP-style timeout, or trigger an auth prompt at session start) and
    any error exits 0 with no output. It surfaces, when available:
    
      - databricks CLI presence + version
      - configured profile names, parsed straight from the config file (no network)
      - the `[__settings__].default_profile` the CLI resolves when --profile is omitted
      - env-based / in-platform auth (DATABRICKS_HOST, DATABRICKS_CONFIG_PROFILE)
    
    Token values are never printed, only their presence.
    
    Contract (Claude Code SessionStart hook; Cursor sessionStart with
    `--platform cursor`):
      stdin : JSON (drained, content unused)
      stdout: platform-shaped JSON carrying the banner string:
              Claude/Codex -> hookSpecificOutput.additionalContext
              Cursor       -> additional_context
    """
    import json
    import os
    import re
    import shutil
    import subprocess
    import sys
    from pathlib import Path
    
    VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
    SECTION_RE = re.compile(r"^\[([^\]]+)\]", re.MULTILINE)
    # default_profile inside the [__settings__] section ([^\[]*? keeps the search
    # from crossing into the next section header).
    SETTINGS_DEFAULT_RE = re.compile(
        r"^\[__settings__\][^\[]*?^[ \t]*default_profile[ \t]*=[ \t]*(\S+)",
        re.MULTILINE,
    )
    MAX_PROFILES = 12
    
    # Per-platform differences: how the setup/doctor commands are invoked there
    # (Claude Code namespaces plugin commands as /databricks:<name>; Cursor has a
    # flat / menu, so the Cursor plugin ships them as /databricks-<name>) and the
    # hook output envelope. Codex consumes the Claude envelope.
    PLATFORMS = {
        "claude": {
            "setup_cmd": "/databricks:setup",
            "commands_blurb": "`/databricks:*` commands",
        },
        "cursor": {
            "setup_cmd": "/databricks-setup",
            "commands_blurb": "`/databricks-*` commands",
        },
    }
    
    
    def _platform_from_argv(argv):
        """Platform from `--platform <name>` / `--platform=<name>`, default claude.
    
        Unknown values fall back to claude rather than erroring: a wiring typo must
        degrade to a working hook, never a broken one.
        """
        for i, arg in enumerate(argv):
            if arg == "--platform" and i + 1 < len(argv):
                value = argv[i + 1]
            elif arg.startswith("--platform="):
                value = arg.split("=", 1)[1]
            else:
                continue
            return value if value in PLATFORMS else "claude"
        return "claude"
    
    
    def cli_version(databricks):
        """(major, minor, patch) from `databricks --version`, or None. 3s timeout."""
        try:
            out = subprocess.run(
                [databricks, "--version"],
                capture_output=True, text=True, timeout=3,
            )
        except Exception:
            return None
        m = VERSION_RE.search((out.stdout or "") + (out.stderr or ""))
        return tuple(int(x) for x in m.groups()) if m else None
    
    
    def config_profiles():
        """(config_path, [profile names], default_profile) read locally from the config file.
    
        Parsed directly rather than via `databricks auth profiles` on purpose: this
        runs at SessionStart, which must stay offline and fast (no network, no
        auth-validation round-trips). Skips CLI-internal sections like
        `[__settings__]`, which are not auth profiles, but does surface
        `[__settings__].default_profile` since the CLI resolves it when --profile
        is omitted.
        """
        cfg = os.environ.get("DATABRICKS_CONFIG_FILE") or str(Path.home() / ".databrickscfg")
        try:
            p = Path(cfg)
            # Only read a regular file under a sane size cap, so a FIFO/device or a
            # huge file pointed at by DATABRICKS_CONFIG_FILE can never hang or do
            # unbounded work at session start.
            if not p.is_file() or p.stat().st_size > 1_000_000:
                return cfg, [], None
            text = p.read_text(errors="replace")
        except Exception:
            return cfg, [], None
        names = [
            n for n in SECTION_RE.findall(text)
            if not (n.startswith("__") and n.endswith("__"))
        ]
        m = SETTINGS_DEFAULT_RE.search(text)
        return cfg, names, (m.group(1) if m else None)
    
    
    def _sanitize(value, limit=64):
        """Make a config-derived string safe to inject as one context list item.
    
        Strips control chars / newlines (so a crafted profile name or env value
        cannot inject extra bullets or instructions) and caps the length.
        """
        s = re.sub(r"[\x00-\x1f\x7f]", " ", str(value))
        s = re.sub(r"\s+", " ", s).strip()
        return s[: limit - 1].rstrip() + "…" if len(s) > limit else s
    
    
    def build_context(platform="claude"):
        """Return the context banner string, or '' to inject nothing."""
        p = PLATFORMS.get(platform, PLATFORMS["claude"])
        databricks = shutil.which("databricks")
        if not databricks:
            return (
                "Databricks CLI (`databricks`) is not on PATH. The Databricks skills "
                f"and {p['commands_blurb']} need it. Run `{p['setup_cmd']}` or see "
                "the databricks-core skill to install it."
            )
    
        lines = []
        ver = cli_version(databricks)
        if ver:
            lines.append(f"CLI v{'.'.join(map(str, ver))}.")
        else:
            lines.append("CLI present (version unknown).")
    
        cfg, profiles, default_profile = config_profiles()
        if profiles:
            shown = [_sanitize(n) for n in profiles[:MAX_PROFILES]]
            more = f" (+{len(profiles) - len(shown)} more)" if len(profiles) > len(shown) else ""
            lines.append(f"Profiles in {_sanitize(Path(cfg).name)}: {', '.join(shown)}{more}.")
            if default_profile:
                lines.append(
                    f"Default profile (from [__settings__]): `{_sanitize(default_profile)}`; "
                    "the CLI uses it when `--profile` is omitted."
                )
            lines.append("Never auto-select a profile. Pass `--profile <name>` and let the user choose.")
        else:
            # Basename only, matching the branch above: the full pa
  • hooks/databricks-router.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """UserPromptSubmit hook: route Databricks-related prompts into the skills.
    
    Reads the user prompt from stdin, runs a fast keyword regex (sub-50ms, no LLM,
    no network), and if the prompt is Databricks-related, injects an
    `additionalContext` instruction telling Claude to load the `databricks-core`
    skill (the parent/router) plus the matching product skill before answering.
    
    There is no second agent to delegate to: Claude itself drives the `databricks`
    CLI through the skills, so "routing" just means "make sure the Databricks skills
    are loaded." No permission gating, no cost warnings.
    
    The keyword lists and the injected instruction are generated from
    plugin.meta.json into `_routing_data.json` next to this file and loaded at
    import time; a minimal inline fallback keeps the hook working if that file is
    missing or unreadable. Regenerate with `python3 scripts/skills.py generate`.
    
    The full routing instruction is injected once per session (keyed by the
    payload's session_id via a marker file in the temp dir); later Databricks
    prompts in the same session get a one-line reminder instead, keeping repeat
    token cost low.
    
    Contract (Claude Code UserPromptSubmit hook):
      stdin : JSON, e.g. {"prompt": "...", "session_id": "..."} or {"message": "..."}
      stdout: JSON -> hookSpecificOutput.additionalContext (injected before the turn),
              or "{}" to stay out of the way.
      Fail-open: on ANY error print "{}" and exit 0, so a broken hook never blocks a
      prompt.
    """
    import json
    import re
    import sys
    import tempfile
    from pathlib import Path
    
    # Routing config (keyword lists + the injected instruction) is generated from
    # plugin.meta.json into _routing_data.json next to this file (see
    # rules/README.md and CONTRIBUTING.md) and loaded at import time. If that file
    # is missing or unreadable the hook stays fail-open via a minimal inline
    # fallback that still routes obvious Databricks prompts.
    _FALLBACK_STRONG = [r"\bdatabricks\b"]
    _FALLBACK_AMBIGUOUS = []
    _FALLBACK_SUPPRESS = []
    _FALLBACK_INSTRUCTION = (
        "[DATABRICKS] This request is Databricks-related. Handle it through the "
        "Databricks skills rather than ad hoc commands: load `databricks-core` (the "
        "parent skill) plus the matching product skill before answering."
    )
    _FALLBACK_REMINDER = (
        "[DATABRICKS] Databricks-related prompt: keep routing through the Databricks "
        "skills (databricks-core plus the matching product skill)."
    )
    
    
    def _load_routing_data(path=None):
        """Load the generated _routing_data.json next to this file; None on any problem.
    
        Returns None (so the inline fallback engages) for a missing, unreadable,
        malformed, wrong-typed, or regex-invalid file. The patterns are compiled
        here, so a corrupt data file degrades the router to "route obvious
        databricks mentions" rather than raising at import (which would otherwise
        take the whole hook down, fallback included).
        """
        try:
            if path is None:
                path = Path(__file__).resolve().parent / "_routing_data.json"
            data = json.loads(Path(path).read_text())
        except Exception:
            return None
        if not isinstance(data, dict):
            return None
        if not all(
            k in data for k in ("strong", "ambiguous", "suppress", "instruction", "reminder")
        ):
            return None
        if not all(isinstance(data[k], list) for k in ("strong", "ambiguous", "suppress")):
            return None
        if not all(isinstance(data[k], str) for k in ("instruction", "reminder")):
            return None
        try:
            for key in ("strong", "ambiguous", "suppress"):
                for pattern in data[key]:
                    re.compile(pattern)
        except (re.error, TypeError):
            return None
        return data
    
    
    _DATA = _load_routing_data()
    
    # STRONG: unambiguously Databricks -> always route, even alongside a mention of
    # an alternative platform (e.g. "migrate from redshift to databricks").
    # AMBIGUOUS: Databricks-likely but also used elsewhere -> route only when no
    # SUPPRESS signal (alternative platform / local dev) is present. STRONG matches
    # ignore SUPPRESS. All are sourced from the generated data file; the inline
    # fallback degrades gracefully (routes only clear "databricks" mentions).
    STRONG = list(_DATA["strong"]) if _DATA else list(_FALLBACK_STRONG)
    AMBIGUOUS = list(_DATA["ambiguous"]) if _DATA else list(_FALLBACK_AMBIGUOUS)
    SUPPRESS = list(_DATA["suppress"]) if _DATA else list(_FALLBACK_SUPPRESS)
    
    # The full instruction injected on the session's first Databricks prompt, plus
    # the one-line reminder for later prompts. ROUTING_INSTRUCTION stays a
    # module-level attribute (scripts/skills.py check_routing_tables reads it).
    ROUTING_INSTRUCTION = _DATA["instruction"] if _DATA else _FALLBACK_INSTRUCTION
    ROUTING_REMINDER = _DATA["reminder"] if _DATA else _FALLBACK_REMINDER
    
    _STRONG = [re.compile(p, re.IGNORECASE) for p in STRONG]
    _AMBIGUOUS = [re.compile(p, re.IGNORECASE) for p in AMBIGUOUS]
    _SUPPRESS = [re.compile(p, re.IGNORECASE) for p in SUPPRESS]
    
    # "databricks" inside a code-hosting URL (github.com/databricks/...) is an
    # org/repo name, not product intent, so URLs are blanked before matching unless
    # the hostname itself contains "databricks" (workspace and docs hosts), which
    # keeps "why is https://myco.cloud.databricks.com/jobs/123 failing?" routing.
    _URL_RE = re.compile(
        r"(?:https?://|git@)(?P<host>[\w.-]+)[/:]?\S*"
        r"|\b(?:www\.)?(?P<bare>(?:github|gitlab|bitbucket)\.(?:com|org))[/:]\S*",
        re.IGNORECASE,
    )
    
    
    def _strip_non_databricks_urls(text):
        def _keep_or_blank(match):
            host = match.group("host") or match.group("bare") or ""
            return match.group(0) if "databricks" in host.lower() else " "
    
        return _URL_RE.sub(_keep_or_blank, text)
    
    
    _SESSION_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]")
    
    
    def _marker_path(session_id):
        """Temp-dir marker recording that this session already got the full instruction."""
        sid = _SESSION_ID_SAFE_RE.sub("", str(session_id or ""))[:64]
        if not sid:
            

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withdatabricks-agent-skills

Skills for AI coding assistants (Claude Code, Cursor, etc.) that provide Databricks-specific guidance.

Get the whole plugin