Skip to content

Pkl essentials ​

hk uses Pkl for typed configuration. Most projects need only a few features: amend the schema, import builtins, define steps, and reuse them across hooks.

Pkl evaluates configuration. hk then runs the commands that configuration defines.

Start with the schema ​

Every project configuration should amend hk’s base schema:

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

amends supplies the allowed properties and classes, such as Step, Hook, and Group. import makes another module available under its name, here Builtins.

Keep both package URLs on the same version. Changing the hk executable does not rewrite pinned imports in hk.pkl.

Values and local variables ​

pkl
local label = "lint"
local workers = 4
local enabled = true
local extensions = List("*.js", "*.ts")

Use local for helper values that are not part of hk’s schema. Without it, Pkl treats the value as a configuration property.

Strings use double quotes, booleans use true and false, and a list uses List(...).

Define a step ​

pkl
local eslint = new Step {
  glob = List("*.js", "*.ts")
  check = "eslint {{files}}"
  fix = "eslint --fix {{files}}"
}

new Step creates an instance of the schema’s step class. {{files}} is an hk command template, expanded later when the step runs; it is not Pkl interpolation.

Reuse steps in mappings ​

Hooks and steps are mappings keyed by name. Prefer top-level steps for linters shared by check, fix, and pre-commit:

pkl
steps {
  ["eslint"] = Builtins.eslint
  ["oxfmt"] = Builtins.oxfmt
}

A mapping entry uses ["name"] = value. Each name must be unique within the mapping.

Top-level steps is optional. To share a mapping between selected explicit hooks instead, use a local helper:

pkl
local linters = new Mapping<String, Step> {
  ["eslint"] = Builtins.eslint
  ["oxfmt"] = Builtins.oxfmt
}

hooks {
  ["check"] { steps = linters }
  ["fix"] {
    fix = true
    steps = new Mapping<String, Step> {
      ...linters
      ["shellcheck"] = Builtins.shellcheck
    }
  }
}

Amend a builtin ​

Parentheses followed by an object body create a modified copy:

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

Unspecified properties keep the builtin’s values. Assigning a new list replaces that property’s list; it does not automatically append to it.

Use raw strings for commands ​

Raw strings help when a command contains quotes or backslashes:

pkl
local json_check = new Step {
  glob = "*.json"
  check = #"jq -e '.' {{files}} >/dev/null"#
}

For longer commands, use a multiline raw string:

pkl
local test = new Step {
  check = #"""
    echo "Running tests"
    mise run test
    """#
}

The closing delimiter determines indentation. Keep the body indented consistently.

Comments ​

pkl
// A comment
/* A multiline comment */
/// A documentation comment
local explanation = "Documentation comments describe the following declaration."

Share configuration across files ​

pkl
amends "./hk.pkl"

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

This is a local amendment of an existing project configuration. Save it as hk.local.pkl and keep it out of version control. The selected file amends hk.pkl; hk does not independently merge those two project files. See local overrides.

Import many files at once ​

import* is a glob import: it binds every file matching the pattern, keyed by its path relative to the importing module.

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

import* "generated/*.pkl" as generated

hooks {
  ["check"] {
    steps = new Mapping<String, Step> {
      for (_, mod in generated) {
        ...mod.STEPS
      }
    }
  }
}

Each generated/*.pkl file contributes its own STEPS, so a build script can add or remove step definitions without editing hk.pkl:

pkl
// generated/oxfmt.pkl
import "package://github.com/jdx/hk/releases/download/v2.11.1/hk@2.11.1#/Config.pkl"

STEPS: Mapping<String, Config.Step> = new {
  ["oxfmt"] {
    glob = List("*.md")
    check = "oxfmt --check {{files}}"
  }
}

Letting each file name its own steps keeps the keys independent of the file names. An imported module is not itself a Step, so read a typed property such as STEPS back out rather than assigning the module into steps directly.

import* also works as an expression, which binds the mapping to a local instead of a module-level name:

pkl
local generated = import*("generated/*.pkl")

A pattern matching nothing produces an empty mapping, and the file holding the import is skipped when the pattern would match it.

The pattern is resolved against the filesystem on every run: adding, removing, or renaming a file the pattern matches takes effect on the next hk command, with no need to touch hk.pkl or run hk cache clear.

Validate and inspect ​

sh
hk validate
hk check --plan

Validation evaluates the configuration without executing linter commands. A plan then shows how hk selects steps and files.

Validation fails on configs hk cannot run, such as a dependency cycle between steps or a glob that does not compile. It also prints warnings, without failing, for settings that load but probably do not do what you meant: a misspelled property (release builds otherwise drop it silently), a depends entry that names an unknown step, a group, or a step in a later group, a step with no command, and a glob that starts with ./, / or ! or lists patterns with commas.

If the Pkl CLI is installed, inspect the evaluated module with:

sh
pkl eval --format json hk.pkl

Use the Pkl language reference for features beyond these examples.

Evaluators ​

hk includes pklr and always uses it to evaluate project, local, and global configuration. The standalone Pkl CLI remains useful for inspecting modules, but it is not an hk runtime dependency or fallback evaluator.

Caching ​

The built-in evaluator persists downloaded packages and seeds the cache with the Pkl package matching the running hk version. Use HK_PKL_OFFLINE to require cached or embedded packages without network access.

Release builds cache evaluated configuration; debug builds disable this cache by default. The values of environment variables the configuration reads with read("env:NAME") or read?("env:NAME") are part of the cache key. hk re-evaluates the configuration only when no cache entry exists for the current values; returning a variable to an earlier value reuses that entry. Files read as resources, such as read("data.txt"), are not tracked. When diagnosing an unexpected result after changing an import or evaluation input, bypass or clear the cache:

sh
HK_CACHE=0 hk validate
hk cache clear

Use hk’s runtime settings, profiles, and command environment where possible instead of making configuration depend on changing evaluation inputs.

MIT LicenseCopyright © 2026jdx.dev