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
1. 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, and whether branch protection should be hard server-side protection or advisory local hook protection
2. 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 |
.dependency-cruiser.* or check:deps exists |
whole-graph architecture check |
.yamllint* exists |
yamllint |
| Team/shared repo | no-commit-to-branch (pre-commit), branch guard (pre-push). For advisory private-repo protection with owner opt-out, use the soft-protected pre-push asset below. |
pnpm-lock.yaml exists |
pnpm build-script decision check - copy assets/pnpm-build-scripts-check.mjs (see below) |
| Test runner detected | test step(s) - vitest/jest/go test/cargo test/pytest |
3. Wire the hooks
Three files to create/update, plus optional extras:
mise.toml- add hk, pkl, tool binarieshk.pkl- configuration.hk-hooks/pre-commit- tracked hook wrapper (runshk run pre-commit -q;-qquiets every step on success - seereferences/output-noise.md).hk-hooks/pre-push- optional, for push-time checks or branch guards. For advisory private-repo branch protection, copy fromassets/soft-protected-branch-pre-push.sh.
Then:
chmod +x .hk-hooks/*
git config --local core.hooksPath .hk-hooks
And add to package.json prepare script (JS projects):
"prepare": "[ -n \"$CI\" ] && exit 0 || git config --local core.hooksPath .hk-hooks"
Wire core.hooksPath directly - don't use hk install here. hk install
succeeds on modern hk/Git and wires hk's own generated hooks, silently
diverging from the tracked .hk-hooks/ wrappers this skill sets up (the
wrapper adds the HK=0 bypass, mise discovery, and -q). One wiring path,
the tracked one. hk needn't be installed at prepare time - the wrapper
discovers it at commit time and errors clearly if missing.
For non-JS projects, set core.hooksPath manually or via a Makefile setup target.
4. 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 // disable OSC terminal-progress escape sequences (NOT stdout noise — see references/output-noise.md)
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
}
Keeping steps quiet - one flag on the hook wrapper
On hk ≥ 1.51.0 the quiet lever is hk run <hook> -q on the .hk-hooks/pre-commit
wrapper. -q natively quiets every step on success: success → 0 bytes,
failure → the failing step's full stdout+stderr survives (only hk's progress chrome is
dropped). No per-step wrapping, no per-tool tiering - steps run their plain commands.
["vitest"] {
check = "pnpm exec vitest run" // chatty on success — silenced by wrapper-level -q
}
Never use --silent: it reaches 0 bytes on success too, but on failure it drops the
diagnostics (you get only See .../output.log). -q is the only safe choice. -n,
HK_LOG, RUST_LOG are no-ops on step success output. See references/output-noise.md for
the mechanism, the TTY-vs-no-TTY nuance, and measured numbers.
Whole-graph checks
Some tools inspect the whole repo graph and should not receive {{files}}:
dependency-cruiser, knip, supply-chain scanners, link checkers, full typechecks,
and coverage gates. Wire them as ordinary steps with no glob when the check
must always see the full graph. (Which supply-chain scan to run, and its
block-vs-report severity split, is the supply-chain-hardening skill's call -
this skill owns the wiring.)
Globless is not only about what the tool accepts. A globbed step does not run at all when the only staged change is a deletion: hk resolves the glob to zero files and the step never appears in the plan, while a globless step still runs (with "0 files"). Any invariant broken by removing a file - a link checker, a dead-reference check, a manifest-vs-tree gate - must therefore be globless, or it goes quiet in exactly the case it exists for.
For dependency-cruiser:
local depcruise_step = new Step {
check = "pnpm --silent check:deps"
}
Use a package script so the long command and config path live with the JS project:
"check:deps": "depcruise src --config .dependency-cruiser.cjs --output-type err-long --no-progress --no-cache"
Prefer putting whole-graph checks in a full quality, check, CI, or pre-push
hook. Promote to staged pre-commit only after measuring the step and confirming
the added latency is acceptable for normal commits.
The .hk-hooks/pre-commit wrapper
This is the file git actually executes. It's tracked in git (unlike .git/hooks/).
Don't capture hk's output - exec it so colour, progress, and failure diagnostics stream
through. The -q flag drops only success chrome (0 bytes on a clean run); a failing step
still streams its full stdout+stderr. The wrapper also adds an HK=0 bypass and discovers hk
via mise when it isn't on PATH:
#!/bin/sh
# hk pre-commit hook — tracked wrapper. Streams hk output directly.
# HK=0 bypasses all hooks (mirrors `HK=0 git commit`).
if [ "${HK:-1}" = "0" ]; then
exit 0
fi
# Find hk: on PATH, else via mise (covers shells without mise activated).
HK_BIN=""
if command -v hk >/dev/null 2>&1; then
HK_BIN="$(command -v hk)"
elif command -v mise >/dev/null 2>&1; then
HK_BIN="$(mise which hk 2>/dev/null || true)"
fi
if [ -z "$HK_BIN" ]; then
echo "hk not found. Install tools with: mise install" >&2
exit 1
fi
exec "$HK_BIN" run pre-commit -q "$@"
For hooks that only delegate to hk, use simpler wrappers:
#!/bin/sh
exec hk run commit-msg "$@"
#!/bin/sh
exec hk run pre-push "$@"
Soft-protected branch pre-push
Use this rarely: small/private/shared repos where server-side branch protection is
unavailable or intentionally advisory, but collaborators should be steered away
from direct pushes to main/master. Prefer server-side branch rules when they
are available. This is not a security boundary: hooks are per clone, require
core.hooksPath, and can be bypassed with --no-verify.
Copy assets/soft-protected-branch-pre-push.sh to .hk-hooks/pre-push and make
it executable:
cp /path/to/skill/assets/soft-protected-branch-pre-push.sh .hk-hooks/pre-push
chmod +x .hk-hooks/pre-push
git config --local core.hooksPath .hk-hooks
Pattern:
- Parse Git's pre-push stdin and block by
remote_ref, not the current branch. Current-branch checks miss pushes likegit push origin feature:main. - Default-block direct pushes to
refs/heads/mainandrefs/heads/master. - Let owner clones opt out with repo-local config:
git config --local hooks.allowMainPush true. - Keep one-off automation escape hatch explicit:
HK_ALLOW_MAIN_PUSH=1 git push. - Document the advisory nature and opt-out in repo docs/agent instructions.
pnpm build-script decision check
A dependency with a lifecycle script (preinstall/install/postinstall, or
a binding.gyp) needs a decision recorded in pnpm-workspace.yaml
allowBuilds. Without one, pnpm 11 fails the install closed
(ERR_PNPM_IGNORED_BUILDS) - but only where nothing masks its check. On a
machine with a global ignoreScripts, the install is green, a cold reinstall
is green, and the failure lands in CI or a platform build instead.
Copy assets/pnpm-build-scripts-check.mjs to .hk-hooks/ and glob the step on
the files that can change the dependency tree:
["pnpm-build-scripts"] {
glob = List("package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml")
check = "node .hk-hooks/pnpm-build-scripts-check.mjs"
}
Pattern:
- Read config, run nothing. The checker parses installed manifests and
pnpm-workspace.yaml. pnpm has no detect-without-execute mode, and its own reporting (pnpm ignored-builds,.modules.yaml) is computed under the masking setting, so it reports the mask rather than the missing decision. The one local command that does reproduce CI -pnpm install --ignore-scripts=false- re-enables the scripts the posture blocks, so it is not a check. - Never brick what it can't evaluate: absent
node_modules, or no pnpm project, warns and exits 0 (same posture as a typecheck step on a fresh clone). --jsonfor machine consumption; the human output caps the listing and reports the true total.- Whether a package gets
trueorfalseis the user's security decision - the supply-chain-hardening skill owns that call. This step only insists the decision exists.
Known limit: it reads the installed tree, so it sees the optional
dependencies resolved for this platform. A postinstall that only ships in a
linux-x64 package is invisible to any local check; only a CI job on the
target platform closes that gap.
Pkl Syntax Reference
Required first lines
amends "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Builtins.pkl"
Always match the version in amends and import to the installed hk version (hk --version),
and require hk ≥ 1.51.0 - the wrapper-level -q success quieting (see above) needs it. The
skill installs hk = "latest", so fresh setups qualify; for an older pinned repo, upgrade hk.
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
}
Output controls
Run-level (preferred): flags on hk run <hook>.
| Flag | Effect |
|---|---|
-q |
Quiet-on-success (hk ≥ 1.51.0): success → 0 bytes; failure keeps the failing step's full stdout+stderr. Put this on the wrapper. |
--silent |
0 bytes on success and on failure - drops diagnostics. Never use it. |
-n / --no-progress |
No-op on step success output (touches progress rendering only). |
Per-step: two knobs that trim hk's chrome - neither suppresses a command's own output
(only wrapper-level -q does that):
["typecheck"] {
check = "pnpm exec tsc --noEmit"
output_summary = "stderr" // end-of-run summary stream: "stderr" (default) | "stdout" | "combined" | "hide"
hide = false // true removes this step's status markers (the ✔/✖ lines)
}
Without -q, on failure hk prints the output twice (live stream + end summary), and
output_summary = "hide" drops the duplicate summary but is only safe under head-keeping
output truncation. Wrapper-level -q sidesteps this: it yields a single small failure copy,
safe under both head- and tail-keeping truncation. See references/output-noise.md.
Custom step
["typecheck"] {
glob = List("*.ts", "*.tsx") // optional: only run when these files staged
check = "pnpm exec tsc --noEmit" // silent on success — no wrapper needed
// 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 |
{{root}} |
Repo root. Inside a tests {} block it still points at the real root, not the sandbox - that is what lets before copy a checker in |
{{tmp}} |
Per-test sandbox directory. tests {} only; using it auto-enables tmpdir |
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 to the installed version (minimum v1.51.0), e.g.:
amends "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/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 = "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 |
Step fails when {{files}} holds nothing the tool handles |
A glob decides what the step runs on, not what the tool accepts: several exit non-zero on an empty target set rather than no-op. oxfmt errors "Expected at least one target file" when every passed path sits in its own ignorePatterns; oxlint does the same given no lintable file. Glob each step to what that tool actually handles, and keep lint and format as separate steps - a combined one globbing *.json fails on a JSON-only commit |
| 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 | Add -q to the pre-commit wrapper (hk run pre-commit -q, hk ≥ 1.51.0): 0 bytes on success, full failing-step output on failure. Never --silent (drops failure diagnostics). See references/output-noise.md |
| 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" |
| A builtin named in the docs does not resolve | The builtin set is tied to the version in your amends/import URL, not to the installed hk. Check that tag's pkl/builtins/ before reaching for one - statix, for instance, is absent at 1.56.1 while deadnix, lychee, check_symlinks, check_case_conflict and hk_test are all present |
hk --all seems to miss files |
It selects tracked files only. An untracked tree under a directory you excluded for size was never in scope, so the exclude may be hiding tracked files for nothing - check with hk check --all --stats before keeping it |
vale fails on a deliberately-malformed frontmatter fixture |
Vale hard-errors (E201) on unparseable frontmatter rather than skipping the file, so test fixtures that are invalid on purpose have to be excluded from the step, the same way lint fixtures are |
pinact fails whenever the machine is offline |
It resolves every action ref through the GitHub API (/repos/<owner>/<repo>/commits/<ref>) and has no offline mode, so an unreachable API is a hard failure (exit 1 on 3.10.1), identical to the one it reports for a genuinely unpinned action. Put it in CI, not pre-commit - the same reason zizmor runs --offline in the hook |
| Step tests write fixtures into the work tree | A bare relative path with no tmpdir = true writes into the repo and leaves the file there. Use {{tmp}}/.... See references/testing-steps.md |
References
references/builtins-by-language.md- step selection by ecosystemreferences/complete-examples.md- full hk.pkl configs for different stacksreferences/output-noise.md- how to keep steps quiet correctly (wrapper-level-q, hk's native controls, harness-truncation caveat)references/testing-steps.md-tests {}andhk test: the fields, the{{tmp}}sandbox rule, testing a whole-repo checker withbefore+{{root}}, and whatBuiltins.hk_testdrags inassets/soft-protected-branch-pre-push.sh- copy to.hk-hooks/pre-pushfor advisory local branch protection with clone-local owner opt-outtests/soft-protected-branch-pre-push.bats- behavioural tests for the advisory branch-protection assetassets/pnpm-build-scripts-check.mjs- copy to.hk-hooks/to fail a commit when a dependency's build script has noallowBuildsdecisiontests/pnpm-build-scripts-check.bats- behavioural tests for the build-script decision checker- hk docs - official documentation
hk builtins- list all available built-in linters