Skip to content

Extensions

A Hunk extension is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. No build step is required.

~/.config/hunk/extensions/hello.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.on("startup", (_event, ctx) => {
ctx.notify("Hello from my extension");
});
}

The API is experimental: hunkdiff/extension may change in breaking ways between minor releases while it stabilizes. Breaking changes are called out in release notes, and hunk.apiVersion identifies the surface an extension was written against.

What an extension can register is covered by the companion pages: the extension API, file previews, VCS adapters, and custom panes.

Writing one with a coding agent? hunk skill path hunk-extensions prints a bundled skill that maps these touchpoints for agents, the way hunk skill path does for reviewing.

GroupSourceRuns
1--extension <path> (repeatable)immediately
2[extensions] paths in your user configimmediately
3~/.config/hunk/extensions/immediately
4.hunk/extensions/ in the repo under reviewafter trust
4[extensions] paths in the repo .hunk/config.tomlafter trust
  • Groups load in order; within a group, entries sort alphabetically by resolved path. The first occurrence of a path wins.
  • The two repo-local sources are one group: one trust decision, one sort order.
  • A directory source matches *.ts, *.tsx, *.js, *.jsx, *.mjs directly inside it, plus one level of folder extensions.
  • --no-extensions disables user extensions for one run; nothing on disk is read.
  • [extensions] disabled and --disable-extension <id> disable one exact extension before its module or factory runs; --enable-extension <id> overrides a configured disable for one run.
  • --extension is explicit intent: it loads immediately, without a trust prompt, even from inside the reviewed repo — so never pass a path you have not read.

A folder is an extension if its package.json declares entries under the hunk field, or failing that if it has an index.{ts,tsx,js,jsx,mjs} (in that preference order):

~/.config/hunk/extensions/my-ext/
package.json # {"hunk": {"extensions": ["./src/index.ts"]}}
node_modules/ # bun install / npm install, right here
src/
index.ts # the declared entry
helper.ts
  • Manifest paths resolve against the folder and may list several entries; each loads as its own extension, in manifest order.
  • The manifest is a real package.json, so a folder extension can depend on npm packages installed into its own node_modules.
  • Pointing --extension or [extensions] paths at a directory works either way: a folder extension loads as one extension; any other directory is scanned as a directory of extensions.

The id is the file stem, or the folder name for <name>/index.ts and single-entry manifests. It is the key for everything the extension owns:

  • config: [extension.<id>]
  • commands: <id>.<commandId>
  • panes: <id>:<paneId>
  • file previews: <id>:<viewId>

Ids start with a letter or digit, then letters, digits, -, or _. hunk, git, jj, and sl are reserved. An invalid id — or a second source offering an already-loaded id — is skipped with a startup notice.

Extensions are shared as Git repositories or npm packages. hunk extension install installs either source under ~/.config/hunk/extensions/installed/, validates its entries, and records the requested source and resolved commit or package version:

Terminal window
hunk extension install acme/hunk-word-diff # GitHub shorthand
hunk extension install acme/hunk-word-diff@v1.2.0 # pin a tag, branch, or commit
hunk extension install git:codeberg.org/acme/ext # any host; https:// is assumed
hunk extension install ~/dev/hunk-word-diff # a local checkout, for testing
hunk extension install npm:@acme/hunk-word-diff
hunk extension install npm:@acme/hunk-word-diff@1.2.0
hunk extension install 'npm:@acme/hunk-word-diff@^1.2.0'
hunk extension install npm:@acme/hunk-word-diff@next
  • hunk extension list shows every managed install with its version, commit, and source.
  • hunk extension update [name] refreshes one install (or all of them) from its recorded source. Git refs and exact npm versions stay pinned; npm ranges and tags are re-resolved.
  • hunk extension remove <name> deletes the install and its record. Hand-copied extensions in ~/.config/hunk/extensions/ are never touched.

Installing is the consent step: extensions run with your full user permissions, so a fresh install asks for confirmation (or takes --yes) after naming the source. Only install sources you trust. Managed installs then load through the global group above — same precedence, no further prompts.

npm installs require Node.js/npm on PATH, honor registry/auth configuration from the launch project's .npmrc, user/global configuration, and environment, and isolate each package's dependencies. Project credentials are forwarded during acquisition, not saved into the managed install. Hunk forces local managed installation even when npm is configured globally. Hoisted helpers receive the same host APIs and React instance as the entry. Lifecycle scripts and automatic peer installation are disabled. If npm attempts a hook despite suppression, Hunk blocks it and fails the staged install. Ship ready-to-load files; packages requiring install-time builds are not supported automatically.

Use npm:<package> from list to update or remove npm packages, for example hunk extension update npm:@acme/hunk-word-diff. npm install directories and single-entry extension IDs contain a readable package prefix plus its SHA-256 digest; this keeps scoped names distinct and IDs stable across versions. Multi-entry packages retain entry-derived IDs. Failed installs or updates do not replace the previous working directory.

Community extensions are listed at hunk.dev/extensions, and the full tail is the hunk-extension topic on GitHub.

A publishable extension repository is the folder-extension layout at the repository root — package.json with a hunk field (or an index.* entry), code, README. To share one:

  1. Fill in package.json's name, version, and description, and declare "hunk": {"apiVersion": N} if you rely on recent API surface — an older Hunk then refuses to load it with a clear message instead of failing partway through.
  2. Keep dependencies real: they are installed into the extension's own node_modules at install time. react, @opentui/*, and hunkdiff/extension come from the host at runtime and belong in devDependencies.
  3. Tag releases so users can pin with @v1.2.0.
  4. Push to any git host and add the hunk-extension GitHub topic, then open a pull request to list it on hunk.dev/extensions.

To publish on npm, explicitly declare hunk.extensions, ensure every entry is a file inside the package, check shipped files with npm pack --dry-run, and run npm publish. Host-provided packages belong in peer or development dependencies, not runtime dependencies.

Test the exact layout users will get with hunk extension install /path/to/checkout, or load it for one run with hunk diff --extension /path/to/checkout.

Hunk's Git, Jujutsu, Sapling, hunk gh, file-navigation, and content-search features use the same public extension API. Bundled extensions differ from yours in three ways:

  • compiled into Hunk and loaded by the lifecycle that owns each capability
  • implicitly trusted, with no [extension.<id>] config table
  • still loaded under --no-extensions and [extensions] enabled = false — those switches triage extensions you installed; selectable bundled capabilities use host-owned ids such as hunk.gh

Extensions run with your full user permissions, and reviewing a repository must never execute code that came with it. So the repo-local sources stay inert until you approve them, once per repository:

Run this repository's extensions?
This repository contains extensions in .hunk/extensions.
Extensions run with your user permissions.
enter/t trust · esc not now · n never

Trust records the decision and reloads the session; not now asks again next time; never stops the offers. The prompt is a dialog over the review stream, not a gate in front of it — dismiss it and keep reviewing.

Decisions are stored per repository root in $XDG_STATE_HOME/hunk/state.json (by default ~/.local/state/hunk/state.json), keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse.

A broken extension is contained, not fatal: a failed import, missing default export, or throwing factory is skipped and rolled back with a startup notice; a handler or transform that throws later becomes a warning naming the extension. Event handlers receive frozen changeset copies, so accidental mutation throws instead of corrupting the review.

Extensions run with your shell permissions. For reviewed files, prefer ctx.workspace; writes require consent and identify the extension and file.

Terminal window
hunk diff --extension ./path/to/entry.ts # load one entry file for a review (repeatable)
hunk diff --extension ./my-ext # a folder extension: loads ./my-ext/index.ts
hunk --extension ./my-ext cli-tools status # run an extension-provided top-level command
hunk gh pr 123 # bundled GitHub review command
hunk --no-extensions cli-tools status # hard-disable lookup and importing
hunk diff --no-extensions # disable user extensions for this review
hunk --disable-extension hunk.gh gh pr 123 # disable one extension for this run
hunk --enable-extension hunk.gh gh pr 123 # override config for this run
# ~/.config/hunk/config.toml or .hunk/config.toml
[extensions]
enabled = true # false disables loading for this layer
paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories
disabled = ["hunk.gh", "my-extension"] # exact selection ids
[extension.my-extension] # opaque payload handed to that extension
some_key = "some value"

[extensions] enabled layers like every other option (repo config overrides user config); --no-extensions is a hard off switch no config layer can re-enable. User and repository disabled lists combine as a union, while --enable-extension is the only one-run override. Disabled user extensions are filtered before import and trust prompting. Extension-provided CLI trees use registerCliCommand; bare help stays static, while hunk <extension-command> --help belongs to the extension. The dependency-free bundled @hunk/gh extension demonstrates direct HTTP preprocessing, cancellation, temporary input ownership, and one-time delegation. [extension.<id>] tables pass through to the extension uninterpreted — see hunk.config for the merge rules and their caveats.

Collapse lockfiles and generated output out of every review, and say how many files were hidden.

~/.config/hunk/extensions/collapse-generated.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
/** Match one path against a `*`-only glob, anchored at both ends. */
function matchesPattern(path: string, pattern: string) {
const source = pattern
.split("*")
.map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${source}$`).test(path);
}
export default function (hunk: HunkExtensionAPI) {
const patterns = (hunk.config.patterns as string[] | undefined) ?? [
"*.lock",
"*-lock.json",
"dist/*",
];
hunk.transformChangeset((changeset, ctx) => {
const kept = changeset.files.filter(
(file) => !patterns.some((pattern) => matchesPattern(file.path, pattern)),
);
const hidden = changeset.files.length - kept.length;
if (hidden > 0) {
ctx.notify(`Collapsed ${hidden} generated ${hidden === 1 ? "file" : "files"}`);
}
return { ...changeset, files: kept };
});
}

Configure it without touching the code:

.hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "bun.lockb", "generated/*"]

Try it against the working tree without installing it:

Terminal window
hunk diff --extension ./collapse-generated.ts

Continue with the extension API for everything the API object offers.