hk — Git Hook Manager
hk by jdx runs linters and formatters as git hooks with built-in parallelism, file locking (no race conditions), and staged-file-only operation (no separate lint-staged needed). Config is in Pkl — Apple's typed configuration language.
Mental Model
Every hk setup is three steps: detect what the project has → compose steps from tiers → wire the hooks in.
detect project type + tools ↓ compose hk.pkl (tiered steps) ↓ wire: mise.toml + .hk-hooks/ + prepare script
Setup Workflow
- Detect
hk --version # get current version for amends URL ls package.json go.mod Cargo.toml pyproject.toml flake.nix Makefile cat mise.toml package.json # existing tools, package manager, scripts
Identify:
-
Language(s) and framework
-
Package manager (pnpm/bun/npm/yarn for JS, cargo, go, pip, etc.)
-
Formatter already configured (prettier, biome, ruff, gofmt…)
-
Linter already configured (eslint, golangci-lint, ruff, clippy…)
-
Test runner (vitest, jest, go test, cargo test, pytest…)
-
Whether it's a team/shared repo (needs no-commit-to-branch)
- Choose steps (tiered)
Tier 1 — Universal (always add):
Step Builtin
trailing-whitespace Builtins.trailing_whitespace
newlines Builtins.newlines
check-merge-conflict Builtins.check_merge_conflict
Tier 2 — Common tools (add if relevant):
Step Builtin When
typos Builtins.typos
Always (fast spell check)
gitleaks custom Always (secret detection)
rumdl Builtins.rumdl
If *.md files exist
Tier 3 — Language-specific (see references/builtins-by-language.md ):
Signal file Steps to add
package.json
- biome.json /biome.jsonc
biome (or ultracite), eslint
package.json (no biome) prettier, eslint
tsconfig.json
typecheck (tsc/tsgo/astro check/svelte-check)
go.mod
go_fmt, go_vet, golangci_lint, gomod_tidy
Cargo.toml
cargo_fmt, cargo_clippy
pyproject.toml /requirements.txt
ruff (format+lint), mypy
flake.nix /*.nix
nix_fmt (nixfmt), deadnix
.sh /.zsh
shfmt, shellcheck
Tier 4 — Project-specific (detect from config files):
Signal Step
commitlint.config.* exists commit-msg hook with commitlint
.yamllint* exists yamllint
Team/shared repo no-commit-to-branch (pre-commit), no-push-to-branch (pre-push)
Test runner detected test step(s) — vitest/jest/go test/cargo test/pytest
- Wire the hooks
Four files to create/update:
-
mise.toml — add hk, pkl, tool binaries
-
hk.pkl — configuration
-
scripts/quiet-on-success.sh — noise suppressor (copy from assets/quiet-on-success.sh in this skill)
-
.hk-hooks/pre-commit — tracked hook wrapper
Then:
chmod +x scripts/quiet-on-success.sh .hk-hooks/* git config --local core.hooksPath .hk-hooks
And add to package.json prepare script (JS projects):
"prepare": "[ -n "$CI" ] && exit 0 || command -v hk >/dev/null && (hk install 2>/dev/null || git config --local core.hooksPath .hk-hooks) || echo 'Note: hk not found, skipping git hooks. Install mise to enable.'"
For non-JS projects, set core.hooksPath manually or via a Makefile setup target.
- Validate
hk check --all # verify all steps pass on existing files hk validate # verify hk.pkl is valid Pkl
Preferred Patterns
hk.pkl global settings
Always use these at the top (after the amends/import lines):
exclude = List("node_modules", "dist", ".next", ".git") // add project-specific dirs display_skip_reasons = List() // suppress skip noise terminal_progress = false // cleaner output
Always use these on the pre-commit hook:
["pre-commit"] { fix = true // auto-fix and re-stage stash = "git" // isolate staged changes steps { ... } }
Binary file excludes
Always exclude binary/font files from trailing-whitespace, newlines, and typos:
local binary_excludes = List( ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".pdf", ".zip" )
["trailing-whitespace"] = (Builtins.trailing_whitespace) { exclude = binary_excludes }
The quiet-on-success wrapper
Wrap noisy commands so output only appears on failure:
["typecheck"] { check = "scripts/quiet-on-success.sh pnpm exec tsc --noEmit" }
Copy assets/quiet-on-success.sh from this skill directory into scripts/ in the target repo.
The .hk-hooks/pre-commit wrapper
This is the file git actually executes. It's tracked in git (unlike .git/hooks/ ):
#!/bin/sh
hk pre-commit hook — silent on success, minimal on failure
if [ -n "$CI" ]; then exec hk run pre-commit "$@" fi output=$(hk run pre-commit "$@" 2>&1) code=$? [ $code -ne 0 ] && printf '%s\n' "$output" exit $code
For other hooks (commit-msg, pre-push), use simpler wrappers:
#!/bin/sh exec hk run commit-msg "$@"
#!/bin/sh exec hk run pre-push "$@"
Pkl Syntax Reference
Required first lines
amends "package://github.com/jdx/hk/releases/download/v1.36.0/hk@1.36.0#/Config.pkl" import "package://github.com/jdx/hk/releases/download/v1.36.0/hk@1.36.0#/Builtins.pkl"
Always match the version in amends and import to the installed hk version (hk --version ).
Builtin step (use as-is)
["trailing-whitespace"] = Builtins.trailing_whitespace
Builtin step (with overrides)
["trailing-whitespace"] = (Builtins.trailing_whitespace) { exclude = List(".png", ".jpg") batch = true }
Custom step
["typecheck"] { glob = List(".ts", ".tsx") // optional: only run when these files staged check = "scripts/quiet-on-success.sh pnpm exec tsc --noEmit" // fix = "command to auto-fix" // optional }
Template variables
Variable Value
{{files}}
Space-separated list of staged files matching the step's glob
{{commit_msg_file}}
Path to commit message file (commit-msg hook only)
{{workspace}}
Directory containing workspace_indicator file
{{workspace_files}}
Files relative to workspace directory
Multi-line inline script
["no-commit-to-branch"] { check = """ branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then echo "Direct commits to '$branch' are not allowed." exit 1 fi """ }
Local variable (share steps across hooks)
local fast_steps = new Mapping<String, Step> { ["trailing-whitespace"] = Builtins.trailing_whitespace ["shfmt"] = (Builtins.shfmt) { batch = true } }
hooks { ["pre-commit"] { fix = true; stash = "git"; steps = fast_steps } ["check"] { steps = fast_steps } ["fix"] { fix = true; stash = "git"; steps = fast_steps } }
Sequential ordering with Groups
Steps within a group run in parallel; groups run sequentially:
steps { ["format"] = new Group { steps = new Mapping<String, Step> { ["prettier"] { ... } ["eslint"] { ... } } } ["validate"] = new Group { // runs after format completes steps = new Mapping<String, Step> { ["typecheck"] { ... } ["test"] { ... } } } }
Or use depends for fine-grained ordering:
["eslint"] { depends = List("prettier") // waits for prettier to finish ... }
mise.toml Additions
[tools] hk = "latest" pkl = "latest" # required for hk.pkl parsing
Add as needed based on detected steps:
typos = "latest" # Tier 2: spell check gitleaks = "latest" # Tier 2: secret detection rumdl = "latest" # Tier 2: markdown lint (if .md files present) yamllint = "latest" # Tier 4: YAML lint (if .yamllint* present)
Maintenance
Add a new step
Insert into hk.pkl under the appropriate section. Check hk builtins for available built-ins, or write a custom step.
Update hk version
hk --version # check current
Bump both URLs in hk.pkl :
amends "package://github.com/jdx/hk/releases/download/v1.37.0/hk@1.37.0#/Config.pkl" import "package://github.com/jdx/hk/releases/download/v1.37.0/hk@1.37.0#/Builtins.pkl"
Bypass hooks temporarily
HK=0 git commit -m "wip" # skip all hk hooks HK_SKIP_STEPS=vitest git commit # skip specific step
Debug a failing step
hk check -v # verbose output hk check -v --step typecheck # single step only hk run pre-commit -v # simulate hook run
Local developer overrides
Create hk.local.pkl (gitignored) to override settings locally:
amends "./hk.pkl" hooks { ["pre-commit"] { steps { ["vitest"] { check = "scripts/quiet-on-success.sh pnpm exec vitest run --testPathPattern=fast" } } } }
Gotchas
Issue Fix
pkl: command not found
Add pkl = "latest" to mise.toml , run mise install
amends version mismatch Match amends/import URL version to hk --version output
Builtins snake_case vs step names kebab-case Builtins.trailing_whitespace → ["trailing-whitespace"]
Hook runs but matches nothing Check glob patterns; use hk check -v to see file matching
Binary files fail spell check Add binary excludes to typos/trailing-whitespace/newlines steps
Git worktrees: hk install fails Automatic since v1.35.0; if using older version use .hk-hooks/
- core.hooksPath
Fix auto-stages wrong files Use explicit stage glob on the step, or ensure step glob covers fixed files
Noisy output on success Wrap commands in scripts/quiet-on-success.sh
Hook runs in CI unnecessarily Add [ -n "$CI" ] && exit 0 to prepare script
hk.local.pkl uses amends not being honoured First line must be amends "./hk.pkl"
References
-
references/builtins-by-language.md — step selection by ecosystem
-
references/complete-examples.md — full hk.pkl configs for different stacks
-
assets/quiet-on-success.sh — copy into scripts/ in target repo
-
hk docs — official documentation
-
hk builtins — list all 90+ available built-in linters