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:
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
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
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:
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:
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:
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:
local json_check = new Step {
glob = "*.json"
check = #"jq -e '.' {{files}} >/dev/null"#
}For longer commands, use a multiline raw string:
local test = new Step {
check = #"""
echo "Running tests"
mise run test
"""#
}The closing delimiter determines indentation. Keep the body indented consistently.
Comments
// A comment
/* A multiline comment */
/// A documentation comment
local explanation = "Documentation comments describe the following declaration."Share configuration across files
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.
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:
// 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:
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
hk validate
hk check --planValidation 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:
pkl eval --format json hk.pklUse 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:
HK_CACHE=0 hk validate
hk cache clearUse hk’s runtime settings, profiles, and command environment where possible instead of making configuration depend on changing evaluation inputs.
