Skip to content

Configuration ​

hk reads hk.pkl to decide which steps to run and how to run them. Start with a shared set of linters, then add file filters, dependencies, and profiles as your project needs them.

For a first setup, use getting started. For complete configurations, see the examples.

hk.pkl ​

A configuration amends hk’s Pkl schema. For a shared set of linters, prefer top-level steps:

pkl
amends "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Builtins.pkl"

steps {
  ["eslint"] = Builtins.eslint
  ["prettier"] = Builtins.prettier
}

pre-commit applies fixes to staged files while unstaged work is saved. The check and fix hooks provide the local commands. They are hooks, not individual steps.

Hook defaults ​

Top-level steps is optional. When nonempty, it creates the check, fix, and pre-commit hooks. check runs checks without fixing or staging. fix applies fixes without staging. pre-commit applies fixes, stages the resulting changes, and defaults to Git stashing so unstaged work is restored after the hook runs.

An explicitly configured hook with one of those names keeps its hook-level settings and replaces same-named inherited steps; top-level steps still supply the remaining step names. Other hook names are custom hooks and must be declared explicitly under hooks with their own steps. Configure fix or stage only when needed; custom hooks are unstaged by default.

You can omit top-level steps and define steps only inside hooks. This is fully supported in v2 and is useful when each hook needs a different set of steps:

pkl
hooks {
  ["check"] {
    steps {
      ["eslint"] = Builtins.eslint
    }
  }
  ["pre-commit"] {
    fix = true
    stage = true
    stash = "git"
    steps {
      ["prettier"] = Builtins.prettier
    }
  }
}

Without top-level steps, hk does not create the three default hooks. The example above declares only check and pre-commit; declare a fix hook too if you want hk fix. Existing typed mappings such as local linters = new Mapping<String, Step> { ... } and steps = linters remain supported.

Staging generated files ​

Hook-level stage enables staging for a hook; step-level stage filters which paths hk adds after a fix. They solve different problems. Staging is enabled by default for pre-commit, but not for manual hk fix or custom hooks. A step's stage list does not enable staging by itself. When set, it is the allowlist: include both changed inputs you want committed and any generated outputs that are outside the step's input file list.

For example, a dependency checker can read a changed manifest but regenerate a repository-wide snapshot that is outside its input file list:

pkl
hooks {
  ["pre-commit"] {
    steps {
      ["dependency-snapshot"] {
        glob = List("package.json")
        fix = "dependency-checker --fix {{files}}"
        stage = List("package.json", ".github/dependency-snapshot.json")
      }
    }
  }
}

The pre-commit hook enables staging, and the step-level allowlist includes both the manifest edit and the generated snapshot. When fail_on_fix is not true, enable staging for a custom hook with stage = true or for a manual run with hk fix --stage. When fail_on_fix = true, hk does not stage fix output. Prefer declaring generated outputs on the step rather than running git add inside the command. Newly created outputs also depend on untracked-file discovery: setting HK_STASH_UNTRACKED to false disables that discovery, so the generated file may not be staged.

Config file paths ​

Starting in the current directory, hk walks upward. At each directory it checks these paths in order, using the first match:

OrderPathPurpose
1hk.local.pklLocal project override
2.config/hk.local.pklLocal override under .config/
3hk.pklShared project configuration
4.config/hk.pklShared configuration under .config/

HK_FILE selects a specific configuration instead. hk selects one project file; it does not merge every file it finds.

hk.local.pkl ​

Use Pkl’s amends to extend the shared project configuration locally:

pkl
amends "./hk.pkl"

hooks {
  ["check"] {
    steps {
      ["local-check"] {
        check = "make local-check"
      }
    }
  }
}

hk selects hk.local.pkl instead of hk.pkl rather than merging the two, so a local file without amends "./hk.pkl" drops the shared configuration entirely. hk warns when that happens; hide the warning with HK_HIDE_WARNINGS=local-config-replaces-shared.

Add hk.local.pkl to .git/info/exclude or the project’s .gitignore. This example preserves inherited steps and adds one. Assign a new mapping when you want to replace the hook’s explicitly declared step list:

pkl
amends "./hk.pkl"

hooks {
  ["check"] {
    steps = new Mapping<String, Step> {
      ["local-check"] { check = "make local-check" }
    }
  }
}

Top-level steps still supply missing names after a hook’s step mapping is replaced. To replace all shared steps for a local configuration, replace the top-level steps mapping too.

Define a step ​

A step selects files and declares commands:

pkl
local eslint = new Step {
  glob = List("*.js", "*.ts")
  exclude = List("**/generated/**")
  check = "eslint {{files}}"
  fix = "eslint --fix {{files}}"
}
  • glob filters the files selected for the run. With no match, the step is skipped.
  • check should return a nonzero status for problems and leave files unchanged.
  • fix should apply available fixes and report any problems that remain.
  • {{files}} expands to the selected file arguments.

A step without file patterns can run even when no files are selected. Use that for whole-project commands, and declare ordering when they read or write beyond a known file set.

Step commands ​

Step commands such as check, check_list_files, check_diff, and fix accept either a shell command string or a structured Command.

String commands run through a shell. Use them when the command needs shell features such as pipes, redirects, &&, variable expansion, or glob expansion:

pkl
check = "eslint {{files}} | tee eslint.log"

Use a structured command to execute a program directly, without a shell:

pkl
check = new Command {
    argv = List("wc", "-c", "{{files}}")
}

The first argv entry is the executable, which hk resolves using PATH. Each remaining entry is passed to the program as one argument after template rendering. Exact, standalone {{files}} and {{workspace_files}} entries are special: hk expands them into one argument per file. {{workspace_files}} contains paths relative to the matched workspace when workspace_indicator is configured.

Structured commands preserve argument boundaries, so filenames containing spaces or shell metacharacters are passed literally. Shell syntax is not interpreted: entries such as "*", "$HOME", "|", and ">" remain literal arguments. Use a string command if shell interpretation is required.

Structured commands cannot be combined with the step's shell option or a string prefix. Use an argv-list prefix such as List("mise", "x", "--") when the structured command should run through a launcher. Other step behavior, including dir, env, and automatic batching for large file lists, continues to apply.

Shell commands on Windows ​

On Windows, a string command runs through cmd.exe unless the step sets shell. A command that is a plain program with arguments behaves the same there, but POSIX shell syntax such as $(...), for loops, [ ... ], trap, or /dev/null does not. A few builtins are written as POSIX scripts and need a POSIX shell on Windows: go_fmt, go_imports, jq, pkl, terraform_docs, terraform_validate, terragrunt_hcl_fmt, terragrunt_hcl_validate, tf_lint, typos, and yq. just_format also needs a POSIX shell, with xargs, and nix_fmt has no Windows commands. Other builtins run as they are.

To run one of them, set its shell to a POSIX shell, such as the sh that comes with Git for Windows. A value that contains quotes groups the quoted words, so a path with spaces can be quoted (on Windows backslashes stay literal, except before a double quote, as in CommandLineToArgvW):

pkl
["jq"] = (Builtins.jq) {
    shell = "\"C:/Program Files/Git/usr/bin/sh.exe\" -o errexit -c"
}

If sh is already on PATH, shell = "sh -o errexit -c" is enough. Include -o errexit -c: it is what hk uses on other platforms, and the scripts rely on it.

go_imports also defines a structured fix, which cannot be combined with shell. Replace it with a string command as well, keeping its write effect:

pkl
["go_imports"] = (Builtins.go_imports) {
    shell = "sh -o errexit -c"
    fix = new CommandSpec {
        command = "goimports -w {{files}}"
        effect = "write"
    }
}

Set shell on the step itself, not on a group that also holds steps with structured commands: a step cannot combine shell with a structured Command, and a group passes its shell to every step that has none.

Literal braces in commands ​

Commands are rendered as Tera templates, so {{ starts an expression. A tool whose own syntax uses {{, such as a Go template, fails to render:

pkl
// error: "{{.ResourceKind}}" is parsed as a hk expression
check = "kubeconform -schema-location 'https://example.com/{{.ResourceKind}}.json' {{files}}"

Wrap the literal part in {% raw %} to pass it through unchanged:

pkl
check = "kubeconform -schema-location 'https://example.com/{% raw %}{{.ResourceKind}}{% endraw %}.json' {{files}}"

Step working directory ​

dir sets the directory a step's commands run in. It is rendered as a template, so a step with workspace_indicator can follow each job's workspace rather than opening every command with a cd:

pkl
local linters = new Mapping {
    ["go-vet"] {
        glob = "**/*.go"
        workspace_indicator = "go.mod"
        dir = "{{workspace}}"
        check = "go vet ./..."
    }
}

hk creates one job per matched workspace, so this runs go vet ./... in packages/api, then in packages/worker, and so on. Because the cd is gone, the command no longer needs a shell and can be written as a structured Command.

{{files}} is relative to the rendered directory, the same as it already is for a literal dir.

File selection happens before hk knows which workspace a job will run in, so glob matching, exclude, and stage pathspecs use only the literal part of dir that precedes the first template expression — sub/{{workspace}} scopes them to sub, and {{workspace}} scopes them to nothing. Use glob and workspace_indicator to select files for a step with a fully templated dir.

For commands run with a literal dir, {{workspace}} and {{workspace_indicator}} are relative to that directory, just like {{files}}. For example, a command running in packages/api sees . and go.mod rather than packages/api and packages/api/go.mod.

stage patterns are handled separately. Staging runs once per step, after every job, so hk re-resolves a templated dir against each matched workspace: stage = List("generated/**") stages packages/a/generated/... and packages/b/generated/..., and leaves a same-named path at the repo root alone. If no workspace matches, the patterns fall back to the repo root and hk warns.

One caveat: while rendering dir itself, {{workspace}} is relative to the repo root, never to a subproject. A subproject config that sets a templated dir therefore resolves to the wrong path. hk reports it as a missing working directory rather than failing obscurely; use a literal dir in subprojects for now.

Focus checks on failing files ​

For tools whose detailed check output cannot identify failing files in a machine-readable form, set check_failed_files = true and provide either check_list_files or check_diff:

pkl
local linters = new Mapping {
    ["my-linter"] {
        glob = List("**/*.py")
        check_list_files = "my-linter --list-failing-files {{files}}"
        check = "my-linter check {{files}}"
        fix = "my-linter fix {{files}}"
        check_failed_files = true
    }
}

In check mode, hk first runs check_diff or check_list_files over the complete job. If that command reports a failure, hk extracts and deduplicates the affected paths, then runs check only on those files so its full diagnostics remain available without rendering every input path again. If both file-reporting commands are configured, check_diff takes precedence.

This behavior is opt-in because it adds another process invocation and requires check to accept file arguments. Enabling it requires check and at least one of check_diff or check_list_files. Paths not present in the original job are ignored, focused commands retain automatic argument-limit batching, and a failure from the file-reporting command remains authoritative if the focused check unexpectedly succeeds.

For partial fixers, set check_after_diff = true alongside check and check_diff. After applying a nonempty diff in fix mode, hk reruns check on the original batch so non-fixable findings are not hidden by a successfully applied patch. Complete formatters can leave this disabled to retain the single-command fast path.

A tool that reports fixes in a SARIF log can get a check_diff from hk util sarif-diff, which runs it and turns the fixes into a patch, as the pinact builtin does. If any result has no fix, it prints no patch, so hk runs fix and the unfixable finding is still reported:

pkl
check_diff = "hk util sarif-diff -- pinact run --check --format sarif {{files}}"

A fixer that only rewrites files that need it, and runs faster than hk can capture and apply a patch, can set apply_check_diff = false. Fix mode then runs fix instead of applying the diff, and check_diff still shows the diff in check mode. In a hook that stages fixes, hk still runs check_diff first and passes fix only the files the diff names, so a passing check skips both the fixer and staging.

A formatter that can read a file on stdin and print the formatted result doesn't need a diff mode of its own. hk util format-diff runs it once per file, in parallel, and prints the patch, as the stylua, tombi_format, buildifier_format, terraform, and tofu builtins do. {} stands for the file's path:

pkl
check_diff = "hk util format-diff {{files}} -- stylua --stdin-filepath {} -"

If the formatter fails for any file, no patch is printed and hk runs fix, which reports the error. A formatter's stdin mode can ignore excludes in its configuration that it applies to files named on the command line, as yamlfmt's and taplo's do; their builtins ask the tool which files would change before formatting those.

Diagnostics ​

hk check --sarif, the diagnostics arrays in --format json and --format jsonl output, and the MCP dashboard all show normalized diagnostics: findings with a file, position, severity, message, and rule. hk builds them by parsing the output of a step's check command, and it can't guess a tool's output format. A step reports diagnostics only when it sets diagnostic_format. Without it the step still runs, fails, and can carry its raw text in the result's output field (separate from diagnostics; output_summary and other step settings decide when it is present), but its diagnostics list and its SARIF results are empty. In --format jsonl output, the findings are in the final run_completed result; each step_completed event carries an empty diagnostics array.

This applies to builtins too. A builtin sets diagnostic_format only when the tool's default output is one of the formats below, because hk doesn't add flags that would change what the tool prints. Most builtins don't set it (check a builtin's definition in pkl/builtins), so an unchanged builtin step contribute no diagnostics even when they fail. To report diagnostics for one of those, set diagnostic_format on your own step, and add the tool's flag for a supported format to its check command if you accept the output changing.

diagnostic_formatWhat hk reads
gccLines like path:line:column: warning: message [rule]. The severity and the trailing [rule] are optional, and lines that follow a diagnostic are added to its message.
sarifA SARIF 2.1.0 log. Each result becomes a diagnostic, with its rule and help link.
eslint-jsonThe JSON array that eslint --format json prints.
cargo-jsonThe stream that cargo check --message-format=json prints. Each compiler-message becomes a diagnostic.

hk parses the combined stdout and stderr of check runs. If a step captured no check output, structured results fall back to the step's retained output, so a failing fix command's output can also be parsed. diagnostic_tool sets the tool name recorded on each diagnostic, which defaults to the step name. Raw text is reported in the separate output field, and output_summary and other step settings decide when it is present. Output that can't be parsed usually becomes an entry in the step's parse_warnings, but not always: the gcc parser appends an unrecognized line that follows a diagnostic to that diagnostic's message, and the cargo-json parser skips valid JSON events that aren't compiler-message. This step reports each line of the compiler's path:line:column: message output as a diagnostic:

pkl
["compiler"] {
    check = "my-compiler {{files}}"
    diagnostic_format = "gcc"
    diagnostic_tool = "my-compiler"
}

Customize a builtin ​

pkl
["prettier"] = (Builtins.prettier) {
  glob = List("*.js", "*.ts", "*.json")
  exclude = List("**/generated/**")
}

The amended object keeps properties you do not override. See builtins for the catalogue and command details.

Dependencies and groups ​

Use depends when the result of one step is needed by another:

pkl
["prettier"] = (Builtins.prettier) {
  depends = "eslint"
}

This waits for the eslint step. File locking already prevents simultaneous writes to selected files; a dependency additionally establishes their order.

Prefer the step’s stage setting over running git add inside a command; hk serializes its own index writes. Serialize commands that write the index themselves with exclusive, depends, or a group.

A Group is a scheduling boundary. Its child steps can run together, but the group waits for prior work and blocks later work until it finishes. Prefer individual dependencies when only a few steps need an order.

Group defaults ​

pkl
local frontend = new Group {
  dir = "frontend"
  prefix = List("mise", "x", "--")
  steps {
    ["prettier"] = Builtins.prettier
    ["eslint"] = Builtins.eslint
  }
}

Groups can provide dir, prefix, workspace_indicator, shell, stage, and exclude. A child inherits a value only when it does not define its own. Child values replace group values; lists are not merged. A builtin may already define a property, so inspect its definition before relying on inheritance.

Profiles ​

Profiles select optional steps:

pkl
["typecheck"] = (Builtins.tsc) {
  profiles = List("slow")
}
sh
hk check --slow
hk check --profile slow
HK_PROFILE=slow hk check

A step requires all of its positive profile names to be enabled. profiles = List("ci", "slow") requires both ci and slow. A negative profile such as "!slow" prevents that step from running when slow is enabled. Quote !slow when passing it through a shell.

Set active profiles at the top level, via CLI flags, Git config, or HK_PROFILE. A hook’s env block configures child commands; it is not the place to select hk’s profiles.

Workspaces ​

Use workspace_indicator for a tool that works on a project identified by a file:

pkl
["cargo-clippy"] = (Builtins.cargo_clippy) {
  workspace_indicator = "Cargo.toml"
  check = "cargo clippy --manifest-path {{workspace_indicator}}"
}

hk partitions selected files by the matching workspace. {{workspace}} is its directory, {{workspace_indicator}} is the marker’s path, and {{workspace_files}} contains paths relative to that directory.

See the monorepo example for component groups and working directories.

Subprojects ​

In a monorepo, the root config can load an hk.pkl owned by each component:

pkl
subprojects = List("frontend", "backend", "packages/*")

Subproject paths are relative to the root config and may be literal directories or glob patterns. hk merges a subproject's steps into the root hook with the same name, then scopes their working directories and file matching to that subproject. A step named eslint in frontend/hk.pkl is exposed as frontend:eslint for --step and skip_steps.

Keep these composition rules in mind:

  • Hooks are not copied between events. A subproject step under check does not also run in pre-commit or fix; add it to every event where it should run.
  • Hook-wide behavior such as fix, stash, stage, and report should be set in the root config. Subprojects contribute steps and their local environment.
  • A subproject's skip_steps name its own steps, and skip only those. A group, or a step inside a group, cannot be skipped from a subproject; hk warns when an entry names one. Other top-level settings in a subproject config, such as exclude, fail_fast, jobs, or profiles, have no effect; hk warns that they are ignored. Set them in the root config.
  • Subprojects are loaded one level deep. A subprojects declaration inside a subproject config is ignored with a warning.
  • A subproject's literal dir is relative to that subproject. Templated workspace directories have an additional caveat described under Step working directory.

See the complete monorepo example, including per-directory mise environments and locally installed Node tools.

Conditions and Git status ​

condition is an expression evaluated per step job. step_condition is evaluated once per step. To run a step only when a shell command succeeds, wrap it in exec_ok(...):

pkl
condition = "exec_ok('test -f .lint-enabled')"

exec_ok(command) is true when the command exits with status 0 and false otherwise. exec(command) returns the command’s standard output as a string, for comparisons such as exec('git branch --show-current') == 'main\n'. Use exec for its output, not to test success: a command that exits non-zero, or prints output that is not valid UTF-8, makes exec fail the hook, and a string result never skips a step.

The git object makes common status checks available without invoking Git:

pkl
condition = "git.staged_files != []"

To require a staged Cargo manifest:

pkl
condition = #"any(git.staged_files, {hasSuffix(#, "Cargo.toml")})"#

Available lists include staged_files, unstaged_files, untracked_files, and modified_files. Staged classifications include staged_added_files, staged_modified_files, staged_deleted_files, staged_renamed_files, and staged_copied_files. Unstaged classifications include unstaged_modified_files, unstaged_deleted_files, and unstaged_renamed_files.

These paths are repository-relative. Git status lists are also available to command templates, for example {{ git.staged_files }}.

Git hook arguments such as hook_args, commit_msg_file, and is_branch_checkout are also available as condition variables. See Other Git events for the variables each hook provides.

Conditions are expr-lang expressions, not Tera templates. Reference variables directly, as in is_branch_checkout, rather than {{ is_branch_checkout }}.

Share configuration across repositories ​

hk has no setting that fetches a configuration from a Git URL. Because hk.pkl is a Pkl module, each repository instead amends a shared module and adds what is specific to it. Each repository still evaluates its own configuration and runs its own steps; a change to the shared module reaches a repository when that repository adopts it.

PatternVersion pinned byWorks offlineNeeds
Self-hosted packagePackage version in URLAfter the first download, from the cacheAny HTTPS file host
Relative amends or a Git submoduleCommitYesCheckout of the shared files
Plain HTTPS module (amends "https://…/hk.pkl")NothingNo: refetched whenever hk re-evaluatesAny HTTPS file host; not recommended

Use a package when many repositories share a configuration and you want versioned releases.

Publish a versioned package ​

A package is a ZIP of Pkl files served from an HTTPS URL. This one shares two builtins that need no external tools. Create a project directory with a PklProject and the shared module:

pkl
amends "pkl:Project"

package {
  name = "acme-hk"
  version = "1.0.0"
  baseUri = "package://hk-config.example.com/acme-hk"
  packageZipUrl = "https://hk-config.example.com/acme-hk@\(version).zip"
}
pkl
amends "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Builtins.pkl"

steps {
  ["trailing_whitespace"] = Builtins.trailing_whitespace
  ["newlines"] = Builtins.newlines
}

Import hk’s schema by its full package:// URL, as above. hk’s evaluator does not resolve Pkl project dependencies, so the @name/… import form that a dependencies block provides fails with Import not found.

Build the package with the Pkl CLI and upload both files it writes, acme-hk@1.0.0 and acme-hk@1.0.0.zip, to the paths in baseUri and packageZipUrl:

sh
pkl project package --output-path out .

A repository then adopts the shared configuration and adds its own steps:

pkl
amends "package://hk-config.example.com/acme-hk@1.0.0#/hk.pkl"

steps {
  ["typecheck"] { check = "tsc --noEmit" }
}

For a package URL on any host, hk downloads https://<host>/<path>/<name>@<version>.zip. It does not fetch the metadata file, so the ZIP is the part hk needs, and the metadata is for the Pkl CLI. The download is kept in HK_PKL_CACHE_DIR under a name derived from that URL, so:

  • A package that has been used once evaluates with no network, including with HK_PKL_OFFLINE=1 and when the host is down. On CI, persist the cache directory between runs to get the same result.
  • A cached version never changes. To change the shared configuration, publish a new version and update the version in each repository’s amends line. Replacing a file at an existing URL does not reach machines that cached it.
  • With HK_PKL_OFFLINE=1, a first run on a machine with no cached copy fails and names the package URL and the cache location.

The shared module above amends hk 2.4.0’s schema. A hk executable of the same version has that package built in, so it needs no download. Pinning a different hk version downloads that version’s package once, from github.com, unless you mirror it.

A host with a certificate from a private certificate authority needs HK_PKL_CA_CERTIFICATES. Without it, the download fails with error sending request.

hk sends no credentials by itself. For a host that needs Basic authentication, see credentials in a rewrite.

Use relative amends or a Git submodule ​

Keep the shared files in a Git repository and amend them by path. As a submodule:

sh
git submodule add https://example.com/acme/hk-shared .hk-shared
pkl
amends ".hk-shared/hk.pkl"

steps {
  ["local-check"] { check = "make local-check" }
}

The shared files can import each other with relative paths, and each can amend or import hk’s schema by its package URL. Updating the submodule commit is the upgrade, and clones need git submodule update --init (or git clone --recurse-submodules, or submodules: true for actions/checkout) before hk can read the configuration. A path outside the repository, such as amends "../shared-config/hk.pkl", works the same way, though other clones need the same layout. hk re-evaluates the configuration when any file it imports changes.

Mirror or redirect downloads ​

HK_PKL_HTTP_REWRITE replaces the start of a URL hk would download, and it takes several rules separated by commas. Set it in the environment of the hk process, such as your shell profile or CI configuration. Use it to serve packages from an internal mirror:

sh
export HK_PKL_HTTP_REWRITE="https://github.com/jdx/hk/releases/download/=https://mirror.example.com/hk/,https://hk-config.example.com/=https://files.internal.example/hk-config/"

With these rules, package://hk-config.example.com/acme-hk@1.0.0 downloads from https://files.internal.example/hk-config/acme-hk@1.0.0.zip. The rewrite is applied to the ZIP URL and to plain HTTPS modules, and the longest matching prefix wins.

The target can carry Basic authentication credentials, which pkl itself does not support for packages: https://user:token@files.internal.example/hk-config/. A failed download prints the rewritten URL, credentials included, so keep that output out of shared logs and use a token with narrow, read-only access. A rule without an = is ignored with a warning, and a comma cannot appear inside a rule.

Behind a proxy ​

Downloads follow the standard proxy variables. hk uses the first of http_proxy, HTTP_PROXY, https_proxy, and HTTPS_PROXY that is set and nonempty for every download, whatever its scheme, and honors no_proxy or NO_PROXY to skip it for some hosts:

sh
HTTPS_PROXY=http://proxy.example.com:3128 NO_PROXY=files.internal.example hk check

A proxy that intercepts TLS needs its CA in HK_PKL_CA_CERTIFICATES.

Configuration precedence ​

Runtime settings resolve from lowest to highest precedence:

PrecedenceSource
1Built-in defaults
2User configuration, typically ~/.config/hk/config.pkl
3Selected project configuration
4Git configuration, with local values overriding global/system values
5HK_* environment variables
6CLI flags

Higher layers override lower ones for scalar settings. List settings such as exclude, skip_steps, skip_hooks, and hide_warnings combine values across sources.

User configuration ​

Use ~/.config/hk/config.pkl for defaults and additional steps across projects. The location follows XDG_CONFIG_HOME or HK_CONFIG_DIR when set.

pkl
amends "package://github.com/jdx/hk/releases/download/v2.5.0/hk@2.5.0#/Config.pkl"

jobs = 4
fail_fast = false
skip_steps = List("optional-check")

For user files amending Config.pkl, hooks and steps merge additively with the project: user configuration adds names the project does not define, and project definitions win on collisions. Use hk.local.pkl to replace project behavior locally.

For removed UserConfig.pkl fields and legacy paths, see the hk v2 migration guide.

Global configuration is separate from global hook installation. An installed hook in a repository without a project configuration exits silently.

Git configuration ​

Use Git settings for persistent preferences without modifying hk.pkl:

sh
git config --local hk.jobs 4
git config --local hk.skipSteps "slow-test,noisy-formatter"
git config --local hk.skipHook pre-push
git config --global hk.failFast false

List settings accept comma-separated values or multiple Git entries:

sh
git config --local hk.exclude node_modules
git config --local --add hk.exclude "**/*.min.js"

Inspect effective settings ​

sh
hk config dump
hk config get exclude
hk config explain jobs

These commands inspect runtime settings. To inspect hook execution, use hk check --plan; to evaluate the Pkl file, use hk validate or the optional Pkl CLI.

Schema reference ​

The following reference is generated from the schema’s documentation. It covers top-level configuration, hooks, steps, and groups.

min_hk_version: String ​

The minimum hk version required by this config.

If the running hk version is older, hk errors out while loading the config.

Example:

pkl
min_hk_version = "1.0.0"

default_branch: String ​

Default: auto-detected

Specifies the preferred default branch to compare against when hk needs a reference (e.g., suggestions in pre-commit warnings). If unset or empty, hk attempts to detect it via origin/HEAD, the current branch's remote, or falls back to main/master if they exist on the remote.

Examples:

pkl
// Use a local branch name
default_branch = "main"

// Or a remote-qualified ref
// default_branch = "origin/main"

Notes:

  • Both local branch names (e.g., main) and remote-qualified refs (e.g., origin/main) are supported.
  • If omitted, hk will detect the default branch based on your repository's remotes and branches.

env: Mapping<String, String> ​

Environment variables can be set in hk.pkl for configuring the linters.

pkl
env {
    ["NODE_ENV"] = "production"
}

exclude: String | List<String> | Regex ​

Default: (empty)

Global exclude patterns that apply to all hooks and steps. Files matching these patterns will be skipped from processing. Supports directory names, glob patterns, and regex patterns.

pkl
// Exclude specific directories
exclude = List("node_modules", "dist", "build")

// Exclude using glob patterns
// exclude = List("**/*.min.js", "**/*.map", "**/vendor/**")

// Single pattern
// exclude = "node_modules"

// Exclude using regex pattern (for complex matching)
// exclude = Regex(#".*\.(test|spec)\.(js|ts)$"#)

Notes:

  • Patterns from all configuration sources are unioned together: the project config, the user config, git config, HK_EXCLUDE, and --exclude
  • Directory names automatically match their contents: "excluded", "excluded/", and "**/excluded" also match everything inside them
  • Can be overridden per-step with <STEP>.exclude
  • Regex patterns use Rust regex syntax and search repo-relative paths, like step-level regex excludes; anchor with ^ to match from the repo root (e.g., Regex(#"^vendor/"#))

fail_fast: Boolean ​

Default: true

Controls whether hk aborts remaining steps/groups after the first failure.

  • When true, as soon as a step fails, hk cancels pending steps in the same hook and returns the error.
  • When false, hk continues running other steps and reports all failures at the end.

hide_warnings: List<String> ​

Warning tags to suppress. Allows hiding specific warning messages that you don't want to see.

Available warning tags:

  • missing-profiles: Suppresses warnings about steps being skipped due to missing profiles

Example: hide_warnings = List("missing-profiles")

All hide configurations from different sources are unioned together.

jobs: UInt ​

The number of parallel processes that hk will use to execute steps concurrently. This affects performance by controlling how many linting/formatting tasks can run simultaneously.

Set to 0 (default) to auto-detect based on CPU cores.

profiles: List<String> ​

Profiles to enable or disable. Profiles allow you to group steps that should run only in certain contexts (e.g., CI, slow tests).

Prefix with ! to explicitly disable a profile.

Example usage:

pkl
profiles = List("ci", "slow")
// Or explicitly disable a profile:
// profiles = List("!slow")

skip_hooks: List<String> ​

A list of hook names to skip entirely. This allows you to disable specific git hooks from running.

For example: skip_hooks = List("pre-commit", "pre-push") would skip running those hooks completely.

This is useful when you want to temporarily disable certain hooks while still keeping them configured in your hk.pkl file. Unlike skip_steps which skips individual steps, this skips the entire hook and all its steps.

All skip configurations from different sources are unioned together.

skip_steps: List<String> ​

A list of step names to skip when running hooks. This allows you to bypass specific linting or formatting tasks.

For example: skip_steps = List("lint", "test") would skip any steps named "lint" or "test".

All skip configurations from different sources are unioned together.

stash_backup_count: UInt ​

Number of backup patch files to keep per repository when using git stash.

Each time git stash is used, hk creates a backup patch file in $HK_STATE_DIR/patches/. This setting controls how many of these backups are retained per repository (oldest are automatically deleted).

Set to 0 to disable patch backup creation entirely.

Default: 20

subprojects: List<String> ​

Directories containing their own hk config files, for monorepos. Entries may be literal directories or glob patterns (e.g. "packages/*").

Each subproject's hooks are merged into this config, scoped to its directory: step working directories and glob matching are relative to the subdirectory, step names are prefixed with "<dir>:" (e.g. packages/web:eslint), and the subproject's env applies only to its own steps.

This pairs well with mise monorepo config roots: each config root can own its linting config next to its code.

pkl
subprojects = List("subproject", "packages/*")

terminal_progress: Boolean ​

Enables or disables reporting progress via OSC sequences to compatible terminals.

walk_ignore: Boolean ​

Controls whether hk respects .gitignore and other ignore files when walking directories.

Default: true

steps: Mapping<String, Step | Group> ​

Steps declared here are shared by the implicit check, fix, and pre-commit hooks. Explicit hook steps with the same name override these definitions.

hooks: Mapping<String, Hook> ​

Hooks define when and how linters are run. See hooks for more information.

hooks.<HOOK> ​

<HOOK>.enabled: Boolean ​

Whether this hook should run and be installed.

Set this to false to disable an implicit hook created by top-level steps.

<HOOK>.fix: Boolean ​

Default: false (true for fix hook)

If true, hk will run the fix command for each step (if it exists) to make modifications.

<HOOK>.stage: Boolean ​

Default: true for pre-commit; false for every other hook.

If true, hk will automatically stage fixed files after fix commands run.

This can be overridden via the stage configuration setting. Note that this means the value of stage in your hk.pkl takes precedence.

<HOOK>.stash: StashMethod ​

Default: "none"

  • "git": Use git stash to stash unstaged changes before running fix steps.
  • "patch-file": Alias of git behavior for now.
  • "none": Do not stash unstaged changes before running fix steps.
  • true (boolean): Alias of "git".
  • false (boolean): Alias of "none".

Examples:

pkl
hooks {
  ["pre-commit"] {
    fix = true
    stash = true        // boolean shorthand for git
    steps = linters
  }

  ["fix"] {
    fix = true
    stash = "none"      // disable stashing
    steps = linters
  }
}

<HOOK>.env: Mapping<String, String> ​

Environment variables specific to this hook. These are merged into each step's environment variables, with step-level env taking precedence.

pkl
hooks {
  ["pre-push"] {
    env {
      ["NODE_ENV"] = "test"
    }
    steps = linters
  }
}

<HOOK>.fail_on_fix: Boolean ​

Default: false

If true, the hook will fail when fix commands modify files. This is useful with stage = false in pre-commit hooks to apply fixes but block the commit so you can review the changes before staging manually.

pkl
hooks {
  ["pre-commit"] {
    fix = true
    stage = false
    fail_on_fix = true
    steps = linters
  }
}

<HOOK>.report: String | Script | Command | CommandSpec ​

Command to run after the hook completes. Receives timing JSON in HK_REPORT_JSON.

A plain string or Script has an unknown effect, so --safe refuses to run the hook. Use a CommandSpec to declare the report's effect (read, write, or destructive).

<HOOK>.steps: Mapping<String, Step | Group> ​

Steps are the individual linters that make up a hook. Steps can run concurrently, subject to dependencies, groups, and file locks, up to HK_JOBS at a time.

hooks.<HOOK>.steps.<STEP> ​

<STEP>.required: List<String> ​

List of environment variables that must be set for this step to run. A variable is considered satisfied if it is present in the process environment, the global env block in hk.pkl, or the step's own env block. If any are missing, the step will be skipped with a clear message.

<STEP>.glob: String | List<String> | Regex ​

Files the step should run on. The step runs when at least one selected file matches the glob or regex patterns. The hook and CLI options select the initial files. If no patterns are provided, the step will always run.

pkl
// Glob patterns
["prettier"] {
    glob = List("*.js", "*.ts")
    check = "prettier --check {{files}}"
}

// Single glob pattern
["eslint"] {
    glob = "*.js"
    check = "eslint {{files}}"
}

// Regex pattern for complex matching
["config-lint"] {
    glob = Regex(#"^(config|settings).*\.(json|yaml|yml)$"#)
    check = "config-lint {{files}}"
}

<STEP>.types: List<String> ​

Default: (none)

Filter files by their type rather than just glob patterns. Matches files by extension, shebang, or content detection (OR logic - file must match ANY of the specified types). This is particularly useful for matching scripts without file extensions.

pkl
// Match Python files by extension AND shebang (including extensionless scripts)
["black"] {
    types = List("python")
    fix = "black {{files}}"
}

// Match shell scripts by extension or shebang
["shellcheck"] {
    types = List("shell")
    check = "shellcheck {{files}}"
}

// Match multiple types (OR logic)
["format-scripts"] {
    types = List("python", "shell", "ruby")
    fix = "format-script {{files}}"
}

// Combine types with glob patterns for more precise filtering
["format-src-python"] {
    glob = "src/**/*"  // Only files in src/
    types = List("python")  // That are Python files
    fix = "black {{files}}"
}

Supported types include:

  • Languages: python, javascript, typescript, ruby, go, rust, java, kotlin, swift, c, c++, csharp, php, "lua"
  • Shells: shell, bash, zsh, fish, sh
  • Data formats: json, yaml, toml, xml, csv, 'pkl'
  • Markup: html, markdown, css, 'asciidoc'
  • Components: svelte, vue, astro
  • Special: text, binary, executable, symlink, dockerfile
  • Images: image, png, jpeg, gif, svg, webp
  • Archives: archive, zip, tar, gzip

Types are detected using:

  1. File extension (e.g., .py → python)
  2. Shebang line (e.g., #!/usr/bin/env python3 → python)
  3. Special filenames (e.g., Dockerfile → dockerfile)
  4. Content/magic number detection for binary files

<STEP>.match_any: List<FileSelector> ​

Match files using any of the supplied selector clauses. Selectors compose with OR semantics, while glob and types within each selector compose with AND semantics.

match_any cannot be combined with the top-level glob or types fields. Each selector must contain a non-empty glob or types value.

pkl
["shellcheck"] {
  match_any = List(
    new FileSelector { glob = List("**/*.sh", "**/*.bash") },
    new FileSelector { types = List("sh", "bash") }
  )
  check = "shellcheck {{files}}"
}

<STEP>.allow_binary: Boolean ​

Whether to include binary files (default: false)

Whether to include symbolic links (default: false)

<STEP>.check: String | Script | Command | CommandSpec ​

A command to run that does not modify files. This typically is a "check" command like eslint or prettier --check that returns a non-zero exit code if there are errors. Parallelization works better with check commands than fix commands as no files are being modified.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                check = "prettier --check {{files}}"
            }
        }
    }
}

If you want to use a different check command for different operating systems, you can define a Script instead of a String:

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                check = new Script {
                    linux = "prettier --check {{files}}"
                    macos = "prettier --check {{files}}"
                    windows = "prettier --check {{files}}"
                    other = "prettier --check {{files}}"
                }
            }
        }
    }
}

Template variables:

  • {{files}}: A list of files to run the linter on.
  • {{workspace}}: When workspace_indicator is set and matched, this is the workspace directory path (e.g., . for the repo root or packages/app).
  • {{workspace_indicator}}: Full path to the matched workspace indicator file (e.g., packages/app/package.json).
  • {{workspace_files}}: A list of files relative to {{workspace}}.

To bypass the shell and pass files as separate arguments, use Command:

pkl
check = new Command {
  argv = List("wc", "-c", "{{files}}")
}

<STEP>.check_list_files: String | Script | Command | CommandSpec ​

A command that returns a list of files that need fixing. This is used to optimize the fix step when check_first is enabled. Instead of running the fix command on all files, it will only run on files that need fixing.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                check_list_files = "prettier --list-different {{files}}"
            }
        }
    }
}

<STEP>.check_diff: String | Script | Command | CommandSpec ​

A command that prints a unified diff of what would be changed, without changing files, and exits non-zero when there are changes.

When defined, fix mode runs this command first and applies its output with git apply instead of running the fix command, and skips the fixer when it exits 0. The output must be a patch git apply accepts, with ---/+++ headers naming each file, as printed by ruff format --diff, shfmt -d, or gofmt -d. Output that only looks like a diff, such as a line-numbered or side-by-side view, never applies: hk then runs the fixer on every file after the check, so the tool runs twice. Put such a command in check, and build a patch with hk util format-diff (a formatter that reads stdin) or hk util sarif-diff (a tool that reports SARIF fixes). A StepTest with run = "diff" fails when the patch doesn't apply.

Falls back to running the fix command if patch application fails.

<STEP>.apply_check_diff: Boolean ​

Default: true

If false, fix mode runs fix instead of applying check_diff output, and check_diff only serves check mode. In a hook that stages fixes, hk still runs check_diff first and then runs fix on just the files the diff names, so a passing check skips both the fixer and staging. Set this for a fixer that only rewrites files that need it and is cheaper to run than producing and applying a patch. A step without fix always applies the diff.

<STEP>.check_after_diff: Boolean ​

Default: false

If true, rerun check on the original file batch after successfully applying check_diff output. Use this for partial fixers whose diff can leave non-fixable findings behind.

Requires both check and check_diff.

<STEP>.check_failed_files: Boolean ​

Default: false

If true, check mode first runs check_diff or check_list_files to identify failing files, then runs check only on those files. This keeps detailed diagnostics while avoiding commands and suggestions containing every file in a large repository.

Requires check and at least one of check_diff or check_list_files.

<STEP>.fix: String | Script | Command | CommandSpec ​

A command to run that modifies files. This typically is a "fix" command like eslint --fix or prettier --write. Templates variables are the same as for check.

pkl
local linters = new Mapping {
    ["prettier"] {
        fix = "prettier --write {{files}}"
    }
}

A hook with fix = true uses fix commands. Disable configured fixes by setting HK_FIX=0 or running hk run <HOOK> --check.

<STEP>.check_first: Boolean ​

Default: false

If true, and another step writes the same files, hk runs this step's check before fix and skips the fix when the check passes, so files that already pass aren't rewritten. The check holds the same write locks as the fix would. When files need fixing, the tool runs twice, which usually costs more than it saves, so it's off by default. Consider it for a slow fixer whose files usually pass.

Some steps check first whatever this is set to:

  • a step whose check and fix are the same command, as in steps migrated from pre-commit: such a fixer exits 1 after fixing, and the second run is what lets it pass;
  • a step with check_diff in fix mode, so it can apply the diff instead of running fix. With apply_check_diff = false, only in a hook that stages fixes, so a passing check skips both the fixer and staging;
  • a step with check_diff or check_list_files and stage = "<JOB_FILES>", so only the files it changed are fixed and staged. With the default stage, this happens only when one of the step's files has unstaged changes that hk didn't stash, since otherwise staging picks up exactly the files the fix changed.

<STEP>.batch: Boolean ​

Default: false

If true, hk will run the linter on batches of files instead of all files at once. This takes advantage of parallel processing for otherwise single-threaded linters like eslint and prettier. Files are split evenly across --jobs batches, with at least batch_min_files files in each, so a handful of files doesn't start one process per file and pay the tool's startup cost each time.

Batched steps that run at the same time share --jobs rather than each splitting its files into --jobs batches. Each step first gets an equal share of the jobs, and the jobs left over go to the steps with the most files. For example, with 8 jobs, prettier on 900 files and eslint on 300 make 4 batches each, and prettier, eslint and jq on 900, 100 and 100 files make 4, 2 and 2. Together they never make more batches than --jobs, unless there are more such steps than jobs, when each still runs once. A step that is the only batched one with files, or that has depends or a condition, gets every job.

pkl
local linters = new Mapping {
    ["eslint"] {
        batch = true
    }
}

<STEP>.batch_min_files: UInt ​

Default: 4

When batch is true, the fewest files hk puts in each batch when it splits a step's files across --jobs. A step with fewer files still runs once, and a batch whose command would be too long for the OS is split further, below this number. Raise it for tools that are slow to start, such as Node-based linters: when other steps keep the CPUs busy, every extra batch mostly pays the tool's startup again.

pkl
local linters = new Mapping {
    ["prettier"] {
        batch = true
        // 900 files make 4 batches instead of one per job
        batch_min_files = 200
    }
}

<STEP>.stomp: Boolean ​

Default: false

If true, bypass hk's file locks for this step. Only use this when another mechanism coordinates access or the command can safely run alongside readers and writers. Read-only checks normally need no such override because they already share read locks.

<STEP>.workspace_indicator: String ​

If set, run the linter on workspaces only which are parent directories containing this filename. This is useful for tools that need to be run from a specific directory, like a project root.

pkl
local linters = new Mapping {
    ["cargo-clippy"] {
        glob = "*.rs"
        workspace_indicator = "Cargo.toml"
        check = "cargo clippy --manifest-path {{workspace_indicator}}"
    }
}

In this example, given a file list like the following:

text
└── workspaces/
    ├── proj1/
    │   ├── Cargo.toml
    │   └── src/
    │       ├── lib.rs
    │       └── main.rs
    └── proj2/
        ├── Cargo.toml
        └── src/
            ├── lib.rs
            └── main.rs

hk will run 1 step for each workspace even though multiple rs files are in each workspace:

  • cargo clippy --manifest-path workspaces/proj1/Cargo.toml
  • cargo clippy --manifest-path workspaces/proj2/Cargo.toml

When workspace_indicator is used, the following template variables become available in commands and env:

  • {{workspace}}: the workspace directory path
  • {{workspace_indicator}}: the matched indicator file path
  • {{workspace_files}}: files relative to {{workspace}}

For example, in a monorepo with Node packages:

pkl
local linters = new Mapping {
  ["npm-lint"] {
    glob = List("*.js", "*.jsx", "*.ts", "*.tsx")
    workspace_indicator = "package.json"
    check = "cd {{workspace}} && npm run lint -- {{workspace_files}}"
    fix = "cd {{workspace}} && npm run fix -- {{workspace_files}}"
  }
}

<STEP>.prefix: String | List<String> ​

If set, run commands with this prefix. Use a string for shell commands or a list of explicit arguments for structured commands.

A check_diff or check_list_files that can't run with the prefix, such as a builtin's shell script under a list prefix, is left out: hk runs check and fix instead. A check or fix that can't run with it is an error.

pkl
local linters = new Mapping {
    ["eslint"] {
        prefix = "npm run"
    }
}

<STEP>.dir: String ​

If set, run the linter scripts in this directory.

pkl
local linters = new Mapping {
    ["eslint"] = (Builtins.eslint) {
        dir = "frontend"
    }
}

dir is a template, so with workspace_indicator set it can follow each job's workspace instead of the command opening with a cd:

pkl
local linters = new Mapping {
    ["go-vet"] {
        glob = "**/*.go"
        workspace_indicator = "go.mod"
        dir = "{{workspace}}"
        check = "go vet ./..."
    }
}

{{files}} is relative to the rendered directory, as it already is for a literal dir.

Note that file selection — glob matching, exclude, and stage pathspecs — happens before hk knows which workspace a job will run in, so it can only use the literal part of dir that precedes the first template expression. dir = "{{workspace}}" therefore scopes nothing, and file selection is left to glob and workspace_indicator.

<STEP>.profiles: List<String> ​

Profiles are a way to enable/disable linters based on the current profile. All positive profiles must be enabled in HK_PROFILE.

pkl
local linters = new Mapping {
    ["prettier"] = (Builtins.prettier) {
        profiles = List("slow")
    }
}

A negative step profile, prefixed with !, skips the step when that profile is enabled.

pkl
local linters = new Mapping {
    ["prettier"] = (Builtins.prettier) {
        profiles = List("!slow")
    }
}

<STEP>.depends: String | List<String> ​

A list of steps that must finish before this step can run.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                depends = List("eslint")
            }
        }
    }
}

<STEP>.allow_failure: Boolean | String ​

Default: false

If true, a non-zero exit from this step's command is reported but does not cause the hook to fail. The step still runs, its output is preserved, and dependent steps may continue. Errors produced by hk itself remain fatal.

A string value is evaluated as an expression when the command fails. The expression can use env(name) to read an environment variable:

pkl
["cargo-check"] {
    check = "cargo check"
    allow_failure = "env('KNOWN_BROKEN') == 'true'"
}

<STEP>.shell: String | Script ​

If set, use this shell instead of the default sh -o errexit -c.

The value is split on whitespace. If it contains quotes, the quoted words are grouped, so a program path with spaces can be quoted (POSIX rules; on Windows backslashes stay literal, except before a double quote, as in CommandLineToArgvW). On Windows the default is cmd.exe, so a step written as a POSIX script, such as the jq builtin, needs shell = "sh -o errexit -c" or the full path to a POSIX sh.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                shell = "bash -o errexit -c"
            }
        }
    }
}

<STEP>.stage: String | List<String> ​

A list of globs of files to add to the git index after running a fix step.

This filters what hk stages; it does not enable staging by itself. When staging is enabled and this is unset, hk stages the files processed by the step. Set it when a fix modifies different files than those it reads.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prettier"] {
                stage = List("*.js", "*.ts")
            }
        }
    }
}

<STEP>.exclusive: Boolean ​

Default: false

If true, this step will wait for any previous steps to finish before running. No other steps will start until this one finishes. Under the hood this groups the previous steps into a group. Prefer declaring generated files with stage and letting hk own Git staging. Use this for legacy or third-party commands that access shared resources hk cannot infer, such as scripts that cannot avoid writing the Git index themselves.

pkl
hooks {
    ["pre-commit"] {
        steps {
            ["prelint"] {
                exclusive = true // blocks other steps from starting until this one finishes
                check = "mise run prelint"
            }
            // ... other steps will run in parallel ...
            ["postlint"] {
                exclusive = true // wait for all previous steps to finish before starting
                check = "mise run postlint"
            }
        }
    }
}

<STEP>.exclude: String | List<String> | Regex ​

Files to exclude from the step. Supports glob patterns and regex patterns. Files matching these patterns will be skipped.

pkl
// Exclude with glob patterns
["prettier"] {
    glob = List("**/*.yaml")
    exclude = List("*.test.yaml", "*.fixture.yaml")
    check = "prettier --check {{files}}"
}

// Exclude with regex pattern for complex matching
["linter"] {
    glob = List("**/*")
    exclude = Regex(#"""
(?x)
^(vendor|dist|build)/.*$|
.*\.(min|bundle)\.(js|css)$|
.*\.generated\.(ts|js)$
"""#)
    check = "custom-lint {{files}}"
}

Notes:

  • A glob naming a directory ("vendor", "vendor/", "**/vendor") also excludes every file inside it. A group's exclude is inherited as is.
  • Regex patterns use Rust regex syntax
  • The (?x) flag enables verbose mode for multi-line patterns with comments
  • Use raw strings (#"..."# or #"""..."""#) to avoid escaping backslashes

<STEP>.interactive: Boolean ​

Default: false

If true, connects stdin/stdout/stderr to hk's execution. This implies exclusive = true.

pkl
local linters = new Mapping {
    ["show-warning"] {
        interactive = true
        check = "echo warning && read -p 'Press Enter to continue'"
    }
}

<STEP>.stdin: String ​

If set, sends the template-expanded string to the command's stdin (mutually exclusive with interactive). Supports variable file_list (list[str]) and Tera builtins: https://keats.github.io/tera/docs/#built-ins.

This is useful for tools which allow passing filenames via stdin (like xargs), to avoid hk's automatic batching.

pkl
local linters = new Mapping {
    ["hungry-command"] {
        stdin = " {{ files_list | join(sep='\n') }}"
        check = "xargs my-command"
    }
}

<STEP>.condition: String ​

If set, the jobs in this step will only run if this condition evaluates to true. This is evaluated per step-job (e.g. multiple times if hk batches, or if there are multiple workspaces) Evaluated with expr.

pkl
local linters = new Mapping {
    ["prettier"] {
        condition = "exec_ok('test -f check.js')"
    }
}

<STEP>.step_condition: String ​

If set, the step will only run if this condition evaluates to true. Evaluated once (regardless of batching or workspaces). Evaluated with expr.

pkl
local linters = new Mapping {
    ["prettier"] {
        condition = "exec_ok('test -f check.js')"
    }
}

<STEP>.hide: Boolean ​

Default: false

If true, the step will be hidden from output.

pkl
local linters = new Mapping {
    ["prettier"] {
        hide = true
    }
}

<STEP>.output_summary: stdout | stderr | combined | hide ​

Default: "stderr"

Controls which stream(s) from the step’s command are captured and printed at the end of the hook run. This prints a single consolidated block per step that produced any output, with a header like STEP_NAME stderr:.

  • "stderr" (default): capture only standard error
  • "stdout": capture only standard output
  • "combined": capture both stdout and stderr interleaved (line-by-line as produced)
  • "hide": capture nothing and print nothing for this step

Examples:

pkl
hooks {
  ["check"] {
    steps {
      ["lint"] {
        check = "eslint {{files}}"
        output_summary = "combined"
      }
      ["format"] {
        check = "prettier --check {{files}}"
        output_summary = "stdout"
      }
      ["quiet-step"] {
        check = "echo noisy && echo warn 1>&2"
        output_summary = "hide"
      }
    }
  }
}

<STEP>.diagnostic_format: DiagnosticFormat ​

Parse the combined stdout and stderr of check runs into normalized diagnostics for structured output (--format json), SARIF export (--sarif), and the MCP dashboard.

A step reports diagnostics only when this is set; without it, diagnostics and its SARIF results are empty and only the raw output is kept. hk does not add flags to the command, so the command must print one of these formats itself:

  • gcc: path:line:column: [severity:] message [rule] lines
  • sarif: a SARIF 2.1.0 log
  • eslint-json: the output of eslint --format json
  • cargo-json: the output of cargo check --message-format=json

When the step sets dir (including dir = "{{workspace}}"), the tool prints paths relative to that directory. hk rebases them onto the repository root, so SARIF, JSON and MCP diagnostics name the file as the repository sees it. Absolute paths are left as printed.

pkl
check = "my-compiler {{files}}"
diagnostic_format = "gcc"

<STEP>.diagnostic_tool: String ​

Tool name recorded on normalized diagnostics. Defaults to the step name.

<STEP>.diagnostic_severity: error | warning | note | help ​

Severity for gcc findings the tool prints without a severity word, when they are shaped path:line: rule-name: message (for example Buildifier's lint findings). Defaults to error. Findings that name a severity keep it, and other lines without one (for example path:3:1: syntax error) stay error.

pkl
diagnostic_format = "gcc"
diagnostic_severity = "warning"

<STEP>.env: Mapping<String, String> ​

Environment variables specific to this step. These are merged with the global environment variables.

pkl
local linters = new Mapping {
    ["prettier"] {
        env {
            ["NODE_ENV"] = "production"
        }
    }
}

<STEP>.tests: Mapping<String, StepTest> ​

Define self-contained tests for a step, runnable via hk test.

Key points:

  • Mapping is keyed by test name.
  • Supported run modes: check or fix (defaults to check).
  • files is optional; if omitted, it defaults to the keys of write.
  • write lets you create files before the test runs (paths can be relative to the sandbox or absolute).
  • fixture copies a directory into a temporary sandbox before the test runs.
  • env merges with the step’s env (test env wins on conflicts).
  • before is an optional shell command to run before the test's main command. If it fails (non-zero exit), the test fails immediately.
  • after is an optional shell command to run after the main command, before evaluating expectations. If it fails, the test fails and reports that failure.
  • expect supports:
    • code (default 0)
    • stdout, stderr substring checks
    • files full-file content assertions

Template variables available in tests are the same as for steps, plus:

  • {{files}}, {{globs}}, {{workspace}}, {{workspace_indicator}}
  • {{root}}: project root
  • {{tmp}}: sandbox path used to execute the test

Example:

pkl
hooks {
  ["check"] {
    steps {
      ["prettier"] {
        check = "prettier --check {{ files }}"
        fix = "prettier --write {{ files }}"
        tests {
          ["formats json via fix"] {
            run = "fix"
            write { ["{{tmp}}/a.json"] = "{\"b\":1}" }
            // files omitted -> defaults to write keys
            expect { files { ["{{tmp}}/a.json"] = "{\n  \"b\": 1\n}\n" } }
          }
          ["check shows output"] {
            run = "check"
            files = List("{{tmp}}/a.json")
            env { ["FOO"] = "bar" }
            expect { stdout = "prettier" }
          }
          ["before generates file, after verifies contents"] {
            run = "fix"
            // before: generate an input file the step will process
            before = #"printf '{\"b\":1}' > {{tmp}}/raw.json"#
            // files: tell hk which file the step should operate on
            files = List("{{tmp}}/raw.json")
            // after: verify the contents using a shell assertion
            after = #"grep -q '\"b\": 1' {{tmp}}/raw.json"#
            // expect: full-file match after formatting
            expect { files { ["{{tmp}}/raw.json"] = "{\n  \"b\": 1\n}\n" } }
          }
        }
      }
    }
  }
}

Run tests with:

bash
hk test                 # all tests
hk test --step prettier # only prettier's tests
hk test --name "formats json via fix"
hk test --list          # list without running

hooks.<HOOK>.steps.<GROUP> ​

<GROUP>.dir: String ​

Working directory inherited by child steps that do not set dir.

This is copied to each child step as a default; child step values override it completely.

<GROUP>.prefix: String | List<String> ​

Command prefix inherited by child steps that do not set prefix.

This is copied to each child step as a default; child step values override it completely.

<GROUP>.workspace_indicator: String ​

Workspace indicator inherited by child steps that do not set workspace_indicator.

This is copied to each child step as a default; child step values override it completely.

<GROUP>.shell: String | Script ​

Shell inherited by child steps that do not set shell.

This is copied to each child step as a default; child step values override it completely.

<GROUP>.stage: String | List<String> ​

Staging globs inherited by child steps that do not set stage.

This is copied to each child step as a default; child step values override it completely. Group and step values are never merged.

<GROUP>.exclude: String | List<String> | Regex ​

Exclude patterns inherited by child steps that do not set exclude.

This is copied to each child step as a default; child step values override it completely. Group and step values are never merged.

<GROUP>.steps: Mapping<String, Step> ​

Child steps in this group.

Settings reference ​

Each setting below lists its type, default, and supported sources. Pkl property names use underscores; CLI flags generally use hyphens.

all ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --all

Select all tracked and eligible untracked files, then apply step filters and exclusions.

With stashing enabled, untracked files are not included. Use hk check --all --plan to inspect the selection.

cache_dir ​

  • Type: path
  • Sources:
    • ENV: HK_CACHE_DIR

Directory for cached configuration and other cache files. The default is the platform cache directory plus hk (typically ~/.cache/hk on Linux and ~/Library/Caches/hk on macOS).

check ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --check, -c
    • ENV: HK_CHECK
    • Git: hk.check

Forces hooks to run their check commands instead of their fix commands.

This is the opposite of the fix setting, and wins over it when both are enabled. The --check and --fix flags outrank both.

Useful for CI environments where you want a report rather than fixes. By convention a check command only reports problems, but hk does not enforce that, so this selects which command runs rather than guaranteeing an unchanged worktree.

check_first ​

  • Type: bool
  • Default: true
  • Sources:
    • ENV: HK_CHECK_FIRST
    • Git: hk.checkFirst

Allows steps that set check_first = true to run their check command before fixing, when another step writes the same files. The fix runs only if the check fails, so files that already pass aren't rewritten.

Steps don't check first unless they set check_first = true, because checking first runs the tool twice whenever files need fixing. A step whose check and fix are the same command, as in steps migrated from pre-commit, always checks first. Set this to false to turn it off for both kinds of step.

default_branch ​

  • Type: string
  • Sources:
    • Pkl: default_branch

Specifies the preferred default branch to compare against when hk needs a reference (e.g., suggestions in pre-commit warnings). If unset or empty, hk attempts to detect it via origin/HEAD, the current branch's remote, or falls back to main/master if they exist on the remote.

Both local branch names (e.g., main) and remote-qualified refs (e.g., origin/main) are supported.

display_skip_reasons ​

  • Type: list<string>
  • Default: ["profile-not-enabled"]
  • Sources:
    • ENV: HK_DISPLAY_SKIP_REASONS
    • Git: hk.displaySkipReasons
    • Pkl: display_skip_reasons

Controls which skip reasons are displayed when steps are skipped.

Available options:

  • all: Show all skip reasons
  • none: Hide all skip reasons
  • disabled-by-config: Show when steps are skipped due to configuration
  • profile-not-enabled: Show when steps are skipped due to missing profiles (default)

Example: HK_DISPLAY_SKIP_REASONS=all to see all skip reasons.

env ​

  • Type: map<string, string>
  • Sources:
    • Pkl: env

Environment variables to set when running linter commands.

These variables are set before executing any step commands and are merged with step-level env settings.

exclude ​

  • Type: list<string>
  • Sources:
    • CLI: --exclude, -e
    • ENV: HK_EXCLUDE
    • Git: hk.exclude
    • Pkl: exclude

Glob patterns to exclude from processing. These patterns are unioned with exclude patterns from other configuration sources (git config, user config, project config). Supports both directory names and glob patterns.

Examples:

  • Exclude specific directories: node_modules,dist
  • Exclude using glob patterns: **/*.min.js,**/*.map

All exclude patterns from different sources are combined. The Pkl exclude setting also accepts a Regex; regexes apply alongside these glob patterns but are not listed by hk config get exclude.

fail_fast ​

  • Type: bool
  • Default: true
  • Sources:
    • CLI: --fail-fast, --no-fail-fast
    • ENV: HK_FAIL_FAST
    • Git: hk.failFast
    • Pkl: fail_fast

Controls whether hk aborts running steps after the first one fails.

When enabled (default), hk will stop execution immediately when a step fails, providing quicker feedback. When disabled, hk will continue running all steps even if some fail, useful for seeing all issues at once.

Can be toggled with --fail-fast / --no-fail-fast CLI flags.

fix ​

  • Type: bool
  • Default: true
  • Sources:
    • CLI: --fix, -f
    • ENV: HK_FIX
    • Git: hk.fix

Permit fix mode when the hook requests it. HK_FIX=0 disables configured fixes unless an explicit fix flag overrides it. A value of true does not make a normal hk check run fixes.

Use --fix or --check to select the mode for an invocation. The check setting, including HK_CHECK=1, wins over fix when both are enabled; explicit CLI flags take precedence.

hide_warnings ​

  • Type: list<string>
  • Sources:
    • ENV: HK_HIDE_WARNINGS
    • Git: hk.hideWarnings
    • Pkl: hide_warnings

Warning tags to suppress. Allows hiding specific warning messages that you don't want to see.

Available warning tags:

  • missing-profiles: Suppresses warnings about steps being skipped due to missing profiles
  • local-config-replaces-shared: Suppresses the warning that hk.local.pkl does not amend the shared hk.pkl, so it replaces it

Example: HK_HIDE_WARNINGS=missing-profiles

All hide configurations from different sources are unioned together.

hide_when_done ​

  • Type: bool
  • Default: false
  • Sources:
    • ENV: HK_HIDE_WHEN_DONE

Controls whether hk hides the progress output when the hook finishes successfully.

When enabled, successful runs will clear their output to reduce visual clutter. Failed runs will always show their output regardless of this setting.

hkrc ​

  • Type: path
  • Default: ".hkrc.pkl"
  • Sources:
    • CLI: --hkrc

Deprecated: Use ~/.config/hk/config.pkl for user defaults or hk.local.pkl for project overrides.

Legacy discovery checks .hkrc.pkl in the current directory, then ~/.hkrc.pkl, before the user configuration directory. The project wins when a user and project definition collide.

This setting and the --hkrc flag will be removed in hk v2.

jobs ​

  • Type: usize
  • Default: 0
  • Sources:
    • CLI: --jobs, -j
    • ENV: HK_JOBS, HK_JOB
    • Git: hk.jobs
    • Pkl: jobs

The number of parallel processes that hk will use to execute steps concurrently. This affects performance by controlling how many linting/formatting tasks can run simultaneously.

Set to 0 (default) to auto-detect based on CPU cores.

Example usage:

  • hk check --jobs 4 - Run with 4 parallel jobs
  • HK_JOBS=8 hk fix - Set via environment variable

json ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --json
    • ENV: HK_JSON
    • Git: hk.json

Request JSON output for commands that support it. For a plan, use hk check --plan --json. For trace events, use HK_TRACE=json hk check. This does not structure arbitrary linter output.

libgit2 ​

  • Type: bool
  • Default: true
  • Sources:
    • ENV: HK_LIBGIT2

Controls whether hk uses libgit2 (a Git library) or shells out to git CLI commands.

When enabled (default), uses libgit2 for better performance in most cases. When disabled, uses git CLI commands which may provide better performance in some cases such as when using fsmonitor to watch for changes.

log_file ​

  • Type: path
  • Sources:
    • ENV: HK_LOG_FILE

Path to the execution log. Defaults to $HK_STATE_DIR/hk.log, typically ~/.local/state/hk/hk.log on Linux. Set a separate file verbosity with HK_LOG_FILE_LEVEL.

log_file_level ​

  • Type: enum
  • Default: "info"
  • Sources:
    • ENV: HK_LOG_FILE_LEVEL

Controls the verbosity of file logging output.

Uses the same levels as log_level but specifically for the log file. Defaults to the same level as log_level if not specified.

This allows you to have different verbosity levels for console and file output.

log_level ​

  • Type: enum
  • Default: "info"
  • Sources:
    • ENV: HK_LOG, HK_LOG_LEVEL

Controls the verbosity of console output.

Available levels (from least to most verbose):

  • off: No logging
  • error: Only errors
  • warn: Errors and warnings
  • info: Normal output (default)
  • debug: Detailed debugging information
  • trace: Very detailed trace information

Example: HK_LOG_LEVEL=debug hk check

mise ​

  • Type: bool
  • Default: false
  • Sources:
    • ENV: HK_MISE

Make hk install launch hooks through mise x, and make hk init create a starter mise.toml when absent.

Git must be able to find mise. Reinstall existing hooks to update their launcher. Steps also receive the mise environment for their working directory, with explicit step environment values taking precedence.

no_progress ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --no-progress

Disables progress bars and real-time status updates.

When enabled, hk will use simpler text output instead of dynamic progress indicators. Useful for CI environments or when output is being logged to a file.

output_file ​

  • Type: path
  • Sources:
    • ENV: HK_OUTPUT_FILE

Path to the file where hk writes the complete output of a failed command.

Default location: ~/.local/state/hk/output.log

An empty value uses the default location.

Useful for preserving full command output when hook summaries are abbreviated.

pkl_ca_certificates ​

  • Type: path
  • Sources:
    • ENV: HK_PKL_CA_CERTIFICATES

A path to a PEM bundle of CA certificates that the built-in pklr evaluator trusts, in addition to the system roots, when it downloads packages and modules.

This setting is read directly from the environment variable before the configuration is evaluated, so it cannot be configured in hk.pkl.

This is useful in corporate environments with SSL-intercepting proxies, and for package hosts whose certificate comes from a private certificate authority.

pkl_http_rewrite ​

  • Type: string
  • Sources:
    • ENV: HK_PKL_HTTP_REWRITE

URL rewrite rules for the built-in pklr evaluator, in the form http(s)://<FROM>/=http(s)://<TO>/. Separate several rules with commas; the longest matching source prefix wins. Package downloads are rewritten as their https://<host>/<path>/<name>@<version>.zip URL.

This setting is read directly from the environment variable before the configuration is evaluated, so it cannot be configured in hk.pkl.

profiles ​

  • Type: list<string>
  • Sources:
    • CLI: --profile, -p
    • ENV: HK_PROFILE, HK_PROFILES
    • Git: hk.profile
    • Pkl: profiles

Enable profile names, or prefix a name with ! to disable it. A step requires all of its positive profiles to be enabled.

Example: HK_PROFILE=ci,slow hk check --all enables both profiles. A profile named ci is not activated simply because the command runs in CI.

quiet ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --quiet, -q

Suppresses non-essential output (info messages, progress indicators).

When enabled, only warnings and errors will be displayed. Useful for scripting or when you only care about the exit code.

silent ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --silent

Suppresses all output including warnings. Only errors are shown.

More extreme than quiet - progress indicators, info messages, and warnings are all hidden. Useful when only the exit code matters.

skip_hooks ​

  • Type: list<string>
  • Sources:
    • ENV: HK_SKIP_HOOK, HK_SKIP_HOOKS
    • Git: hk.skipHooks, hk.skipHook
    • Pkl: skip_hooks

A list of hook names to skip entirely. This allows you to disable specific git hooks from running.

For example: HK_SKIP_HOOK=pre-commit,pre-push would skip running those hooks completely.

This is useful when you want to temporarily disable certain hooks while still keeping them configured in your hk.pkl file. Unlike skip_steps which skips individual steps, this skips the entire hook and all its steps.

This setting can also be configured via:

  • Git config: git config hk.skipHook "pre-commit"
  • User config (~/.config/hk/config.pkl): skip_hooks = List("pre-commit")

All skip configurations from different sources are unioned together.

skip_steps ​

  • Type: list<string>
  • Sources:
    • CLI: --skip-step
    • ENV: HK_SKIP_STEPS, HK_SKIP_STEP
    • Git: hk.skipSteps, hk.skipStep
    • Pkl: skip_steps

A list of step names to skip when running hooks. This allows you to bypass specific linting or formatting tasks.

For example: HK_SKIP_STEPS=lint,test would skip any steps named "lint" or "test".

This setting can also be configured via:

  • Git config: git config hk.skipSteps "step1,step2"
  • User config (~/.config/hk/config.pkl): skip_steps = List("step1", "step2")

All skip configurations from different sources are unioned together.

slow ​

  • Type: bool
  • Default: false
  • Sources:
    • CLI: --slow, -s

Enables the "slow" profile for running additional checks that may take longer.

This is a convenience flag equivalent to --profile=slow.

Useful for thorough checking in CI or before major releases.

stage ​

  • Type: bool
  • Sources:
    • CLI: --stage, --no-stage
    • ENV: HK_STAGE
    • Git: hk.stage
    • Pkl: stage

When specified, overrides the hook's stage key.

Without an override, staging is enabled only for pre-commit. Manual hk fix and every other hook leave changes unstaged.

This is useful when you want to manually review changes made by auto-fixers before including them in your commit.

stash ​

  • Type: enum
  • Sources:
    • CLI: --stash
    • ENV: HK_STASH
    • Git: hk.stash

Override the hook's strategy for saving unstaged work.

  • git: Save unstaged changes using Git stashing.
  • patch-file: Currently an alias for the Git stash implementation.
  • none: Leave unstaged work in place; fixes may then include unstaged edits in a staged file.

Without an override, use the hook's stash value, which defaults to none. The pre-commit hook that hk builds from top-level steps (the setup hk init creates) uses git.

stash_backup_count ​

  • Type: usize
  • Default: 20
  • Sources:
    • ENV: HK_STASH_BACKUP_COUNT
    • Git: hk.stashBackupCount
    • Pkl: stash_backup_count

Number of backup patch files to keep per repository when using git stash.

Each time git stash is used, hk creates a backup patch file in $HK_STATE_DIR/patches/. This setting controls how many of these backups are retained per repository (oldest are automatically deleted).

Set to 0 to disable patch backup creation entirely.

Default: 20

stash_lock_timeout ​

  • Type: usize
  • Default: 300
  • Sources:
    • ENV: HK_STASH_LOCK_TIMEOUT
    • Git: hk.stashLockTimeout

Seconds to wait for another hk process to finish stashing in the same repository.

While hk holds unstaged changes aside, it keeps a lock in the repository's common git directory (hk-stash.lock), which linked worktrees share. A second hk process that needs to stash waits for the first to restore its changes, printing a message while it waits, and fails naming the lock file if the wait passes this limit.

Set to 0 to fail at once instead of waiting.

Default: 300

stash_untracked ​

  • Type: bool
  • Default: true
  • Sources:
    • ENV: HK_STASH_UNTRACKED
    • Git: hk.stashUntracked

Include untracked files when stashing. Disabling HK_STASH_UNTRACKED also skips untracked-file discovery, so those files are absent from status-based reports and normal --all selection.

state_dir ​

  • Type: path
  • Sources:
    • ENV: HK_STATE_DIR

Directory where hk stores persistent state files.

Default location: ~/.local/state/hk

Includes logs, temporary patch files for stashing, and other state information.

summary_text ​

  • Type: bool
  • Default: false
  • Sources:
    • ENV: HK_SUMMARY_TEXT

In plain-text mode, summaries are shown for failed steps by default. Set HK_SUMMARY_TEXT=1 to include summaries for successful steps too; their output normally streams during execution.

terminal_progress ​

  • Type: bool
  • Default: true
  • Sources:
    • ENV: HK_TERMINAL_PROGRESS
    • Git: hk.terminalProgress
    • Pkl: terminal_progress

Enables or disables reporting progress via OSC sequences to compatible terminals.

timing_json ​

  • Type: path
  • Sources:
    • ENV: HK_TIMING_JSON

Path to write a JSON timing report after a hook finishes. The report includes total wall time and per-step wall time, with overlapping intervals merged so time isn't double-counted across parallel step parts.

The steps field maps step names to objects containing:

  • wall_time_ms: merged wall time in milliseconds
  • profiles (optional): list of profiles required for that step

Example usage: HK_TIMING_JSON=/tmp/hk-timing.json hk check

Example output:

json
{
  "total": { "wall_time_ms": 12456 },
  "steps": {
    "lint": { "wall_time_ms": 4321, "profiles": ["ci", "fast"] },
    "fmt": { "wall_time_ms": 2100 }
  }
}

When a hook-level report command is configured in hk.pkl, hk will set HK_REPORT_JSON to the same timing JSON content and execute the command after the hook finishes.

trace ​

  • Type: enum
  • Default: "off"
  • Sources:
    • CLI: --trace
    • ENV: HK_TRACE
    • Git: hk.trace

Enables tracing spans and performance diagnostics for detailed execution analysis.

Available formats:

  • off: No tracing (default)
  • json: Machine-readable JSON trace output
  • 1 or true: Human-readable trace output

Useful for debugging performance issues or understanding execution flow.

Example: HK_TRACE=1 hk check to enable text tracing with the environment variable.

verbose ​

  • Type: u8
  • Default: 0
  • Sources:
    • CLI: --verbose, -v

Increase log verbosity: -v enables debug logging, and -vv enables trace logging.

Example: hk check -v.

walk_ignore ​

  • Type: bool
  • Default: true
  • Sources:
    • ENV: HK_WALK_IGNORE
    • Git: hk.walkIgnore
    • Pkl: walk_ignore

Respect .gitignore and other ignore files while walking directories. This controls discovery; step patterns and exclusions still apply.

Set HK_WALK_IGNORE=0 to disable ignore rules for directory walks.

warnings ​

  • Type: list<string>
  • Sources:
    • ENV: HK_WARNINGS
    • Git: hk.warnings
    • Pkl: warnings

Warning tags to enable or show. Controls which warning messages are displayed during execution.

MIT LicenseCopyright © 2026jdx.dev