diff --git a/.changeset/bundled-extensions-skill.md b/.changeset/bundled-extensions-skill.md
new file mode 100644
index 000000000..d64bed63e
--- /dev/null
+++ b/.changeset/bundled-extensions-skill.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": minor
+---
+
+Ship an extension-authoring skill for coding agents and let `hunk skill path [name]` print any bundled skill.
diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml
index 678260ad4..e87a619ef 100644
--- a/.github/workflows/release-prebuilt-npm.yml
+++ b/.github/workflows/release-prebuilt-npm.yml
@@ -264,10 +264,12 @@ jobs:
echo "Missing release binary in $directory" >&2
exit 1
fi
- if [ ! -f "$directory/skills/hunk-review/SKILL.md" ]; then
- echo "Missing bundled Hunk review skill in $directory" >&2
- exit 1
- fi
+ for skill in hunk-review hunk-extensions; do
+ if [ ! -f "$directory/skills/$skill/SKILL.md" ]; then
+ echo "Missing bundled Hunk $skill skill in $directory" >&2
+ exit 1
+ fi
+ done
chmod 0755 "$binary"
tar -C "$(dirname "$directory")" -czf "dist/release/github/${package_name}.tar.gz" "$package_name"
done < <(find dist/release/artifacts -mindepth 1 -maxdepth 1 -type d -name 'hunkdiff-*' -print0 | sort -z)
diff --git a/AGENTS.md b/AGENTS.md
index 87a0b9dc0..44e5f8b32 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,7 @@ CLI input
- Pager mode has two paths: full diff UI for patch-like stdin, plain-text fallback for non-diff pager content.
- View defaults are layered through built-ins, user config, repo `.hunk/config.toml`, command sections, pager sections, and CLI flags.
- `hunk daemon serve` runs one loopback daemon that brokers agent commands to many live Hunk sessions. Normal Hunk sessions should auto-start and register with that daemon when session brokering is enabled. Keep it local-only and session-brokered rather than opening per-TUI ports.
-- Extensions come in two tiers — user TypeScript extensions and the bundled tier in `src/extensions/default/` — running through one per-extension API object and registry (`src/extensions/runExtension.ts`, resolved via `src/extensions/apply.ts`). Every shipped VCS backend and the built-in sidebar are bundled extensions registering through the public API; that dogfooding keeps `hunkdiff/extension` honest. Hard rules: `src/extension-api/types.ts` stays import-free (declaration emission publishes whatever it reaches; `scripts/check-pack.ts` gates it); `src/extensions/default/vcs/` loads from VCS adapter resolution and must stay renderer-free (the sidebar loads separately via `getBundledSidebarView`); repo-local `.hunk/extensions/` never executes without the trust prompt; bundled extensions stay loaded under `--no-extensions`. The full architecture — host-served runtime modules, sidebar pane model, command dispatch, VCS detection ordering, conversion boundaries — is mapped in `docs/extension-architecture.md` and documented in depth by the module headers it names; the authoring guide is `docs/extensions.md`.
+- Extensions come in two tiers — user TypeScript extensions and the bundled tier in `src/extensions/default/` — running through one per-extension API object and registry (`src/extensions/runExtension.ts`, resolved via `src/extensions/apply.ts`). Every shipped VCS backend and the built-in sidebar are bundled extensions registering through the public API; that dogfooding keeps `hunkdiff/extension` honest. Hard rules: `src/extension-api/types.ts` stays import-free (declaration emission publishes whatever it reaches; `scripts/check-pack.ts` gates it); `src/extensions/default/vcs/` loads from VCS adapter resolution and must stay renderer-free (the sidebar loads separately via `getBundledSidebarView`); repo-local `.hunk/extensions/` never executes without the trust prompt; bundled extensions stay loaded under `--no-extensions`. The full architecture — host-served runtime modules, sidebar pane model, command dispatch, VCS detection ordering, conversion boundaries — is mapped in `docs/extension-architecture.md` and documented in depth by the module headers it names; the authoring guide is `docs/extensions.md`, and `skills/hunk-extensions/SKILL.md` is the agent-facing map of those touchpoints.
- Agent rationale is optional sidecar JSON matched onto files/hunks.
- The order of `files` in the sidecar is intentional. Hunk uses that order for the sidebar and main review stream.
- Prefer one source of truth for each user-visible behavior. When rendering, navigation, scrolling, or note placement share the same model, derive them from the same planning layer rather than maintaining parallel implementations.
diff --git a/docs/extensions.md b/docs/extensions.md
index 4b7dad9fd..d9331195b 100644
--- a/docs/extensions.md
+++ b/docs/extensions.md
@@ -22,6 +22,10 @@ export default function (hunk: HunkExtensionAPI) {
> changes will be called out in release notes, and `hunk.apiVersion` identifies
> the surface an extension was written against.
+Writing one with a coding agent? `hunk skill path hunk-extensions` prints a
+bundled skill that maps the touchpoints below for agents, the way
+`hunk skill path` does for reviewing.
+
## Where Hunk looks for extensions
Discovery runs group by group, alphabetically by resolved path within each
diff --git a/package.json b/package.json
index aef6d381a..5d0e23b5b 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"bin",
"dist/npm",
"skills/hunk-review",
+ "skills/hunk-extensions",
"README.md",
"LICENSE"
],
diff --git a/scripts/build-prebuilt-artifact.test.ts b/scripts/build-prebuilt-artifact.test.ts
index 8f71d7dd4..7d06c49ba 100644
--- a/scripts/build-prebuilt-artifact.test.ts
+++ b/scripts/build-prebuilt-artifact.test.ts
@@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } f
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
+import { BUNDLED_SKILL_NAMES } from "../src/core/paths";
import { stagePrebuiltArtifact } from "./build-prebuilt-artifact";
import { binaryFilenameForSpec, getHostPlatformPackageSpec } from "./prebuilt-package-helpers";
@@ -15,11 +16,18 @@ function createTestRepo() {
const binaryName = binaryFilenameForSpec(spec);
mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
- mkdirSync(path.join(repoRoot, "skills", "hunk-review"), { recursive: true });
writeFileSync(path.join(repoRoot, "dist", binaryName), "#!/bin/sh\necho hunk\n", {
mode: 0o600,
});
- writeFileSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), "# Hunk review\n");
+
+ for (const skillName of BUNDLED_SKILL_NAMES) {
+ mkdirSync(path.join(repoRoot, "skills", skillName), { recursive: true });
+ writeFileSync(path.join(repoRoot, "skills", skillName, "SKILL.md"), `# ${skillName}\n`);
+ }
+
+ // A maintainer-only skill the artifact must leave behind.
+ mkdirSync(path.join(repoRoot, "skills", "launch-video"), { recursive: true });
+ writeFileSync(path.join(repoRoot, "skills", "launch-video", "SKILL.md"), "# Launch video\n");
return { repoRoot, spec, binaryName };
}
@@ -39,14 +47,25 @@ describe("stagePrebuiltArtifact", () => {
expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing skills directory");
});
- test("rejects missing bundled Hunk review skill with an actionable error", () => {
+ test("rejects a missing bundled skill with an actionable error", () => {
const { repoRoot } = createTestRepo();
rmSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), { force: true });
- expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing bundled Hunk review skill");
+ expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow(
+ "Missing bundled Hunk hunk-review skill",
+ );
});
- test("includes the bundled skill next to standalone release binaries", () => {
+ test("rejects a missing bundled skill added after the first one", () => {
+ const { repoRoot } = createTestRepo();
+ rmSync(path.join(repoRoot, "skills", "hunk-extensions", "SKILL.md"), { force: true });
+
+ expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow(
+ "Missing bundled Hunk hunk-extensions skill",
+ );
+ });
+
+ test("includes every bundled skill next to standalone release binaries", () => {
const { repoRoot, spec, binaryName } = createTestRepo();
const outputRoot = path.join(tempRoot!, "artifacts");
@@ -55,7 +74,12 @@ describe("stagePrebuiltArtifact", () => {
expect(outputDir).toBe(path.join(outputRoot, spec.packageName));
expect(existsSync(path.join(outputDir, binaryName))).toBe(true);
expect(existsSync(path.join(outputDir, "metadata.json"))).toBe(true);
- expect(existsSync(path.join(outputDir, "skills", "hunk-review", "SKILL.md"))).toBe(true);
+ for (const skillName of BUNDLED_SKILL_NAMES) {
+ expect(existsSync(path.join(outputDir, "skills", skillName, "SKILL.md"))).toBe(true);
+ }
+
+ // Maintainer-only skills reference scripts no artifact ships, so they stay out.
+ expect(existsSync(path.join(outputDir, "skills", "launch-video"))).toBe(false);
if (process.platform !== "win32") {
expect(statSync(path.join(outputDir, binaryName)).mode & 0o111).not.toBe(0);
diff --git a/scripts/build-prebuilt-artifact.ts b/scripts/build-prebuilt-artifact.ts
index 71e53cc52..454e2e814 100644
--- a/scripts/build-prebuilt-artifact.ts
+++ b/scripts/build-prebuilt-artifact.ts
@@ -2,6 +2,7 @@
import { chmodSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
+import { BUNDLED_SKILL_NAMES } from "../src/core/paths";
import {
binaryFilenameForSpec,
getHostPlatformPackageSpec,
@@ -77,12 +78,18 @@ export function stagePrebuiltArtifact(options: StagePrebuiltArtifactOptions = {}
throw new Error(`Missing skills directory at ${skillsSource}.`);
}
- const hunkReviewSkill = path.join(skillsSource, "hunk-review", "SKILL.md");
- if (!existsSync(hunkReviewSkill)) {
- throw new Error(`Missing bundled Hunk review skill at ${hunkReviewSkill}.`);
- }
+ // Stage the bundled skills by name rather than the whole directory: `skills/`
+ // also holds maintainer-only documents that reference paths no artifact ships.
+ for (const skillName of BUNDLED_SKILL_NAMES) {
+ const skillSource = path.join(skillsSource, skillName, "SKILL.md");
+ if (!existsSync(skillSource)) {
+ throw new Error(`Missing bundled Hunk ${skillName} skill at ${skillSource}.`);
+ }
- cpSync(skillsSource, path.join(outputDir, "skills"), { recursive: true });
+ cpSync(path.join(skillsSource, skillName), path.join(outputDir, "skills", skillName), {
+ recursive: true,
+ });
+ }
writeFileSync(
path.join(outputDir, "metadata.json"),
`${JSON.stringify(
diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts
index 82d34fe84..b952eb8a5 100644
--- a/scripts/check-pack.ts
+++ b/scripts/check-pack.ts
@@ -255,9 +255,10 @@ const requiredPaths = [
"README.md",
"LICENSE",
"package.json",
- // The bundled review skill must survive the narrowed "skills/hunk-review"
- // files entry — `hunk skill path` depends on it at runtime.
+ // The bundled skills must survive the narrowed per-skill files entries —
+ // `hunk skill path [name]` resolves them at runtime.
"skills/hunk-review/SKILL.md",
+ "skills/hunk-extensions/SKILL.md",
];
for (const path of requiredPaths) {
diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts
index 35be78509..a52c0e700 100644
--- a/scripts/check-prebuilt-pack.ts
+++ b/scripts/check-prebuilt-pack.ts
@@ -72,6 +72,7 @@ assertPaths(metaPack, [
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"skills/hunk-review/SKILL.md",
+ "skills/hunk-extensions/SKILL.md",
"README.md",
"LICENSE",
"package.json",
diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts
index 4c9bcceb6..0e7495d7c 100644
--- a/scripts/smoke-prebuilt-install.ts
+++ b/scripts/smoke-prebuilt-install.ts
@@ -157,16 +157,21 @@ try {
);
}
- const skillPath = run([installedHunk, "skill", "path"], {
- env: commandEnv,
- }).stdout.trim();
- if (
- !skillPath.endsWith(path.join("skills", "hunk-review", "SKILL.md")) ||
- !existsSync(skillPath)
- ) {
- throw new Error(
- `Expected installed hunk skill path to resolve to the bundled skill.\n${skillPath}`,
- );
+ // The bare command keeps naming the review skill; every bundled skill must
+ // also resolve by name, since the install is what users discover them through.
+ const skillPathChecks: [args: string[], skillName: string][] = [
+ [["skill", "path"], "hunk-review"],
+ [["skill", "path", "hunk-review"], "hunk-review"],
+ [["skill", "path", "hunk-extensions"], "hunk-extensions"],
+ ];
+
+ for (const [args, skillName] of skillPathChecks) {
+ const skillPath = run([installedHunk, ...args], { env: commandEnv }).stdout.trim();
+ if (!skillPath.endsWith(path.join("skills", skillName, "SKILL.md")) || !existsSync(skillPath)) {
+ throw new Error(
+ `Expected installed \`hunk ${args.join(" ")}\` to resolve the bundled ${skillName} skill.\n${skillPath}`,
+ );
+ }
}
const bunCheck = Bun.spawnSync(
diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md
new file mode 100644
index 000000000..e015b3ad2
--- /dev/null
+++ b/skills/hunk-extensions/SKILL.md
@@ -0,0 +1,249 @@
+---
+name: hunk-extensions
+description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — hiding or reordering reviewed files, sidebar panes, alternate file views, commands and key bindings, dialogs, workspace writes, themes, syntax languages, VCS backends, lifecycle events. Use when writing, debugging, or installing a Hunk extension, or when a request asks Hunk itself to behave differently. Not for reviewing a diff in a live session — that is hunk-review.
+---
+
+# Building Hunk extensions
+
+A Hunk extension is **one TypeScript (or JSX/JS) file that default-exports a
+factory**. Hunk imports it at startup and hands it an API object. No build step,
+no manifest required.
+
+```ts
+// ~/.config/hunk/extensions/hello.ts
+import type { HunkExtensionAPI } from "hunkdiff/extension";
+
+export default function (hunk: HunkExtensionAPI) {
+ hunk.on("startup", (_event, ctx) => ctx.notify("Hello"));
+}
+```
+
+This skill is a map of the touchpoints, not a recipe. Decide what to build from
+the user's request; use the table below to find the call, then read the linked
+material before writing code.
+
+## Sources of truth — read before writing
+
+| Source | What it answers |
+| --------------------------------------- | ------------------------------------------------------------ |
+| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. |
+| `src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. |
+| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. |
+| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. |
+| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. |
+
+Outside a Hunk checkout the guide is split across
+ (discovery, trust, config) and its
+companion pages — extension-api, file-previews, vcs-adapters, custom-sidebars —
+and the contract ships as `node_modules/hunkdiff/dist/npm/extension/index.d.ts`.
+
+The examples, by what they demonstrate:
+
+- `review-triage/` — sidebar + commands + all three dialog shapes + lifecycle
+ events + the extension event bus + a `useSyncExternalStore` bridge.
+- `inline-edit/` — an interactive file-view `mode` driving `ctx.workspace` writes;
+ its README explains the async lifetime rules better than anything else in tree.
+- `rendered-markdown/` — a file view producing host-rendered rows from parsed
+ Markdown, and a folder extension with an npm dependency.
+- `jsx-file-view/`, `jsx-file-view-gallery/` — the experimental fixed-height JSX
+ row component contract.
+
+## Where extensions live
+
+| Source | Trust |
+| ------------------------------------------ | ---------------- |
+| `--extension ` (repeatable) | runs immediately |
+| `[extensions] paths` in user config | runs immediately |
+| `~/.config/hunk/extensions/` (XDG-aware) | runs immediately |
+| `.hunk/extensions/` or repo-config `paths` | **trust prompt** |
+
+Only the repo-local group is gated. Everything else — including `--extension`,
+even when its path points inside the repository under review — is read as
+explicit user intent and executes with full user permissions, no prompt. Never
+pass or suggest a path you have not read, including one copied from a
+repository's own README.
+
+A directory matches `*.ts`/`*.tsx`/`*.js`/`*.jsx`/`*.mjs` at its top level, plus
+one level of folder extensions. A folder is an extension if it has a
+`package.json` with `{"hunk": {"extensions": ["./index.ts"]}}`, or an
+`index.{ts,tsx,js,jsx,mjs}`. Reach for a folder only when you need npm
+dependencies, helper modules, or a README; a single file keeps the install to one
+`cp`. Hunk never installs anything, so a folder extension's `node_modules` has to
+exist on every machine that loads it — keep a repo-shared extension
+dependency-free.
+
+The **id** is the file stem, or the folder name for a folder extension — unless
+its manifest declares several entries, in which case each entry is its own
+extension named by its own stem (numeric suffix on collision). The id is the
+namespace it owns: commands are `.`, sidebar views
+`:`, config `[extension.]`. Ids match
+`/^[A-Za-z0-9][A-Za-z0-9_-]*$/`; `hunk`, `git`, `jj`, and `sl` are reserved. A
+bad or duplicate id is skipped with a startup notice.
+
+## Pick the touchpoint
+
+| To do this | Call |
+| ------------------------------------------------------- | -------------------------------------------- |
+| Add a selectable color theme | `hunk.registerTheme(theme)` |
+| Highlight an unrecognized file extension | `hunk.registerFileLanguage(ext, lang)` |
+| Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` |
+| Add a navigation/list/status pane beside the review | `hunk.registerSidebarView(view)` |
+| Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) |
+| Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` |
+| Hide, reorder, retitle files before review | `hunk.transformChangeset(fn)` |
+| React to loads, selection, viewed files, notes, reloads | `hunk.on(event, handler)` |
+| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
+| Read user-supplied settings | `hunk.config` (`[extension.]` table) |
+| Branch on the API generation (currently `2`) | `hunk.apiVersion` |
+
+Registration is only valid while the factory runs — Hunk seals the API object
+afterwards.
+
+## What handlers receive
+
+Every event, bus, command, and file-view mode handler — plus every changeset
+transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's
+`matches` and `layout` get no context at all. Beyond that:
+
+- **Event and bus handlers** also get `ctx.sidebars` (open/close/toggle/isOpen on
+ any view) and `ctx.events.emit`.
+- **Command handlers** get `ctx.sidebars`, `ctx.fileViews` (select/toggle/isActive/
+ refresh/enterMode/exitMode), `ctx.selection` (a snapshot of file + hunk index),
+ `ctx.navigation` (live, guarded `selectFile`/`selectHunk`), `ctx.dialogs`
+ (`confirm`/`select`/`input`, queued and attributed), and `ctx.workspace`
+ (`readDocument`, `canWriteDocument`, `writeDocument` with consent).
+- **Sidebar components** get props: `files` (frozen, filtered, review order, each
+ with `hunks` summaries), `selectedFileId`, `selectedHunkIndex`, `width`,
+ `theme` (hex tokens plus an `appearance` flag — see `ExtensionPaintTheme`),
+ `keybindings` (ask by command id, never hard-code a chord), and `actions`
+ (`selectFile`, `selectHunk`, `notify`).
+- **File-view `layout`** gets `file`, `width`, `signal`, `changes`, and a lazy
+ `readDocument(side)`.
+- **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey` must
+ answer **synchronously** — its return value (`"handled"`/`"pass"`/`"exit"`) is
+ the routing decision, so kick off async work and report it later through
+ `notify` or `refresh`. Escape is host-owned and never reaches `onKey`.
+
+Event payloads, sidebar props, and a command's selection all hand you frozen
+`ExtensionDiffFile` / `ExtensionDiffHunk` views. A changeset transform is the
+exception: it receives the live changeset and is expected to return a new one.
+`metadata` is unfrozen either way — it is the renderer's parsed diff, so pass it
+through untouched.
+
+## Rules that bite
+
+Most extension bugs are one of these:
+
+- **Registering a surface does not show it.** A sidebar view starts closed unless
+ it declares `defaultOpen` (or `replacesDefault`, which starts open in place of
+ the built-in file list). A file view never activates itself — raw diff is the
+ default and the user picks the view from the **View** menu. Ship a command that
+ toggles it and say which key, or correct code looks like it did nothing.
+- **A rejected file-view layout silently becomes raw diff.** `hunkRows` needs one
+ in-bounds, inclusive entry per parsed hunk at the same array index, and
+ `sourceRanges` may not overlap on a side; invalid, oversized, cancelled, and
+ throwing layouts warn once and fall back.
+- **Never bundle or vendor React.** Hunk serves its own `react` and `@opentui/*`
+ to extension files; a second copy means a second hooks dispatcher and the
+ component fails to render. Import them normally. OpenTUI intrinsics (`box`,
+ `text`, `scrollbox`) need no import.
+- **`layout` is a pure derivation of `(file, width)`.** A stateful view keeps
+ painting its first answer until `ctx.fileViews.refresh(viewId)` — scope it with
+ `{ fileId }` when the state belongs to one file.
+- **Handler state must live outside the component.** Panes unmount when closed;
+ bridge module-level state into React with `useSyncExternalStore` and immutable
+ snapshots (`review-triage/index.tsx` is the working version).
+- **A reload keeps your factory and renames the files.** Factories re-run only
+ after a trust grant or a cwd change, so module state survives — but a file's
+ `id` encodes its position in the changeset, so a reload that adds or drops a
+ file renumbers the rest. Key durable per-file state by `path`, or reconcile it
+ on `changeset_loaded`. Pick one deliberately.
+- **Transforms must preserve `metadata`** (spreading a file does), keep ids
+ unique, and return a real changeset — otherwise the transform is skipped with a
+ warning and the previous changeset carries forward.
+- **Chords are defaults.** Users remap by command id in `[keybindings]`; built-ins
+ win conflicts, refused one chord at a time. Bind the character shift produces
+ (`"!"`, not `"shift+1"`).
+- **Repo config can set `[extension.]` for a globally installed extension.**
+ Treat `hunk.config` as untrusted for anything exec-adjacent (binary paths,
+ shell commands, module loading).
+- **`ctx.workspace` writes only apply to reloadable, unstaged working-tree
+ reviews**, by reviewed file id, inside the review root, with consent. Everything
+ else returns `{ ok: false, reason }` — check `canWriteDocument` first.
+- **File-view note placement is all-or-raw per file**: an unbound or range-less
+ visible note makes Hunk render the complete raw diff instead of guessing.
+- **Failures are contained, not sandboxed.** A throwing factory is rolled back to
+ zero registrations and a throwing handler is a warning naming the extension —
+ containment against bugs, not against code that should not have been loaded.
+- **The API touches nothing outside the review.** No clipboard, no filesystem, no
+ process surface beyond `ctx.workspace` — an extension is ordinary code, so shell
+ out for the rest. Never write to stdout: the renderer owns it. For the same
+ reason `hunk.log` is collected as diagnostics and printed nowhere; `ctx.notify`
+ is how a user hears from you.
+- **`HunkExtensionUserError`** (detected structurally by `name`) buys the full
+ treatment — message plus `suggestions`, no stack trace — only from a VCS adapter
+ operation, which is where Hunk formats it for the CLI. From a command or event
+ handler only the message survives, as a warning toast.
+
+## Verifying
+
+Hunk's TUI needs a real terminal, and the review UI is the user's — **do not
+launch `hunk diff`/`hunk show` to test, and do not reach for a pipe.** No
+invocation applies extensions headlessly: `hunk diff … | cat` still starts the
+app and still takes the keyboard, so it hangs holding the user's terminal.
+Practical checks, in order of cost:
+
+1. **Typecheck.** In a checkout, `bun run typecheck` covers
+ `examples/extensions/**` via the `hunkdiff/extension` path mapping. Standalone,
+ add `hunkdiff` as a dev dependency and run `tsc --noEmit`; for a `.tsx`
+ extension also add `react`, `@types/react` (React ships no declarations of its
+ own), `@opentui/core`, and `@opentui/react` as **dev** dependencies and set
+ `"jsx": "react-jsx"` with
+ `"jsxImportSource": "@opentui/react"`, or every `` and `` is an
+ untyped intrinsic. Types only — shipping those packages is the second-React bug.
+2. **Unit-test the logic.** When parsing, matching, or formatting is worth
+ testing, put it in helper modules with plain `bun test` coverage.
+3. **PTY integration.** In a checkout, `test/pty/extensions-integration.test.ts`
+ launches Hunk over a PTY with `--extension ` and asserts on rendered
+ snapshots; extend it via `test/pty/harness.ts` and run `bun run test:integration`.
+4. **Hand it to the user** to run: `hunk diff --extension ./my-ext`. `--extension`
+ loads immediately with no trust prompt, so it is the iteration path. Ask them
+ what the footer notices and toasts said.
+5. **Triage with `--no-extensions`** to confirm a symptom belongs to an extension
+ (bundled VCS backends and the built-in sidebar stay loaded either way).
+
+## If it does not load
+
+- No startup notice at all → a successful load is silent, so either it loaded and
+ nothing opened it, or discovery never saw the file. Check the directory, the
+ entry suffix, or the folder's `package.json` `hunk.extensions` paths.
+- Notice naming the extension → id rejected (reserved, malformed, or already
+ claimed), import failure, missing default export, or a throwing factory.
+- Repo-local extension silently absent → the trust prompt was dismissed or denied;
+ decisions are stored per repo root in `~/.config/hunk/state.json`.
+- Sidebar pane closes with a toast → the component threw; a second React copy is
+ the usual cause.
+- Sidebar or file view never appears → nothing opened it (no `defaultOpen`, no
+ command), `matches` returned false, or the layout was rejected.
+- Command never fires → its chord lost to a built-in or an earlier extension (a
+ warning says so); it is still reachable from the **Extensions** menu and
+ bindable by `.`.
+
+## Changing Hunk itself
+
+Only when the work is in the `hunk` repo rather than in a user extension:
+
+- Shipped VCS backends and the built-in sidebar are **bundled extensions** in
+ `src/extensions/default/`, registering through the same public API. That
+ dogfooding is deliberate — if the public contract cannot express something,
+ that is a real gap, not a reason for a private path. `default/vcs/` loads from
+ VCS adapter resolution and must stay renderer-free.
+- `src/extension-api/types.ts` must stay **import-free**; declaration emission
+ publishes whatever it reaches, and `scripts/check-pack.ts` fails the pack
+ otherwise. Shapes shared with internal code are declared there and re-exported
+ inward.
+- New API surface means updating `docs/extensions.md` (its examples are
+ typechecked as consumer code), the matching hand-written page under
+ `website/src/content/docs/docs/extend/` (only `cli.md` and `config.md` are
+ generated), `docs/extension-architecture.md` if ownership moves, and a changeset.
+- `AGENTS.md` and `docs/extension-architecture.md` own the rest of these rules.
diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts
index 0ac924770..99240dd73 100644
--- a/src/core/cli.test.ts
+++ b/src/core/cli.test.ts
@@ -298,17 +298,34 @@ describe("parseCli", () => {
expect(parsed.text).toEndWith(`${join("skills", "hunk-review", "SKILL.md")}\n`);
});
+ test("prints a named bundled skill path, by name or alias", async () => {
+ for (const requested of ["hunk-extensions", "extensions"]) {
+ const parsed = await parseCli(["bun", "hunk", "skill", "path", requested]);
+
+ expect(parsed.kind).toBe("help");
+ if (parsed.kind !== "help") {
+ throw new Error("Expected bundled skill path output.");
+ }
+
+ expect(parsed.text).toEndWith(`${join("skills", "hunk-extensions", "SKILL.md")}\n`);
+ }
+ });
+
test("prints skill help for hunk skill --help", async () => {
const parsed = await parseCli(["bun", "hunk", "skill", "--help"]);
expect(parsed).toEqual({
kind: "help",
text: [
- "Usage: hunk skill path",
+ "Usage: hunk skill path [name]",
"",
- "Print the bundled Hunk review skill path.",
+ "Print a bundled Hunk skill path.",
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
"",
+ "Skills:",
+ ` hunk-review (default, "review") review a live Hunk session with \`hunk session\` commands`,
+ ` hunk-extensions ("extensions") build extensions against the hunkdiff/extension API`,
+ "",
].join("\n"),
});
});
@@ -1256,9 +1273,16 @@ describe("parseCli argument validation", () => {
await expect(parseCli(["bun", "hunk", "skill", "bogus"])).rejects.toThrow(
"Only `hunk skill path` is supported.",
);
- await expect(parseCli(["bun", "hunk", "skill", "path", "extra"])).rejects.toThrow(
- "`hunk skill path` does not accept additional arguments.",
+ await expect(parseCli(["bun", "hunk", "skill", "path", "bogus"])).rejects.toThrow(
+ 'Unknown skill "bogus". Bundled skills are hunk-review and hunk-extensions.',
);
+ // Maintainer-only skills are not bundled, so naming one is not a path lookup.
+ await expect(parseCli(["bun", "hunk", "skill", "path", "launch-video"])).rejects.toThrow(
+ 'Unknown skill "launch-video".',
+ );
+ await expect(
+ parseCli(["bun", "hunk", "skill", "path", "hunk-review", "extra"]),
+ ).rejects.toThrow("`hunk skill path` accepts at most one skill name.");
await expect(parseCli(["bun", "hunk", "daemon", "bogus"])).rejects.toThrow(
"Only `hunk daemon serve` is supported.",
);
diff --git a/src/core/cli.ts b/src/core/cli.ts
index f95b66d09..24347c8ef 100644
--- a/src/core/cli.ts
+++ b/src/core/cli.ts
@@ -12,7 +12,12 @@ import type {
SessionCommentListType,
SessionCommentApplyItemInput,
} from "./types";
-import { resolveBundledHunkReviewSkillPath } from "./paths";
+import {
+ BUNDLED_SKILL_NAMES,
+ resolveBundledSkillName,
+ resolveBundledSkillPath,
+ type BundledSkillName,
+} from "./paths";
import {
type AgentCommandConstraint,
type AgentCommandSpec,
@@ -181,8 +186,8 @@ export const CLI_REFERENCE_COMMANDS = {
},
"skill-path": {
path: "skill path",
- summary: "print the bundled Hunk review skill path",
- synopsis: ["hunk skill path"],
+ summary: "print a bundled Hunk skill path",
+ synopsis: ["hunk skill path [name]"],
},
"daemon-serve": {
path: "daemon serve",
@@ -342,19 +347,23 @@ function renderCliVersion() {
return `${resolveCliVersion()}\n`;
}
-/** Render the bundled Hunk review skill path for shell usage. */
-function renderHunkReviewSkillPath() {
- return `${resolveBundledHunkReviewSkillPath()}\n`;
+/** Render one bundled skill path for shell usage. */
+function renderBundledSkillPath(name?: BundledSkillName) {
+ return `${resolveBundledSkillPath(name)}\n`;
}
/** Build the `hunk skill` help text. */
function renderSkillHelp() {
return [
- "Usage: hunk skill path",
+ "Usage: hunk skill path [name]",
"",
- "Print the bundled Hunk review skill path.",
+ "Print a bundled Hunk skill path.",
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
"",
+ "Skills:",
+ ` hunk-review (default, "review") review a live Hunk session with \`hunk session\` commands`,
+ ` hunk-extensions ("extensions") build extensions against the hunkdiff/extension API`,
+ "",
].join("\n");
}
@@ -377,7 +386,7 @@ function renderCliHelp() {
" hunk session inspect or control a live Hunk session",
" hunk markup render ( | -) preview experimental STML note markup",
" hunk markup guide print the experimental STML authoring guide",
- " hunk skill path print the bundled Hunk review skill path",
+ " hunk skill path [name] print a bundled Hunk skill path",
" hunk daemon serve run the local Hunk session daemon",
"",
"Global options:",
@@ -1362,13 +1371,25 @@ async function parseSkillCommand(tokens: string[]): Promise {
};
}
- if (rest.length > 0) {
- throw new Error("`hunk skill path` does not accept additional arguments.");
+ if (rest.length > 1) {
+ throw new Error("`hunk skill path` accepts at most one skill name.");
+ }
+
+ const [requestedName] = rest;
+ if (requestedName === undefined) {
+ return { kind: "help", text: renderBundledSkillPath() };
+ }
+
+ const name = resolveBundledSkillName(requestedName);
+ if (!name) {
+ throw new Error(
+ `Unknown skill "${requestedName}". Bundled skills are ${BUNDLED_SKILL_NAMES.join(" and ")}.`,
+ );
}
return {
kind: "help",
- text: renderHunkReviewSkillPath(),
+ text: renderBundledSkillPath(name),
};
}
diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts
index 2462573f2..d0c9e4695 100644
--- a/src/core/paths.test.ts
+++ b/src/core/paths.test.ts
@@ -3,7 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
- resolveBundledHunkReviewSkillPath,
+ BUNDLED_SKILL_NAMES,
+ resolveBundledSkillName,
+ resolveBundledSkillPath,
resolveCanonicalPath,
resolveGlobalConfigPath,
resolveHunkStatePath,
@@ -41,13 +43,41 @@ describe("paths", () => {
);
});
- test("locates the bundled Hunk review skill from source", () => {
- const resolvedPath = resolveBundledHunkReviewSkillPath([import.meta.dir]);
+ test("locates the bundled Hunk review skill from source by default", () => {
+ const resolvedPath = resolveBundledSkillPath(undefined, [import.meta.dir]);
expect(resolvedPath).toEndWith(join("skills", "hunk-review", "SKILL.md"));
});
- test("locates the bundled Hunk review skill through a nested hunkdiff package", () => {
+ test("locates every bundled skill from source by name", () => {
+ for (const skillName of BUNDLED_SKILL_NAMES) {
+ expect(resolveBundledSkillPath(skillName, [import.meta.dir])).toEndWith(
+ join("skills", skillName, "SKILL.md"),
+ );
+ }
+ });
+
+ test("resolves bundled skill names and their short aliases", () => {
+ expect(resolveBundledSkillName("hunk-extensions")).toBe("hunk-extensions");
+ expect(resolveBundledSkillName("extensions")).toBe("hunk-extensions");
+ expect(resolveBundledSkillName(" Review ")).toBe("hunk-review");
+ expect(resolveBundledSkillName("launch-video")).toBeUndefined();
+ expect(resolveBundledSkillName("")).toBeUndefined();
+ });
+
+ test("names the missing skill when one cannot be located", () => {
+ const tempRoot = createTempRoot("hunk-skill-missing-");
+
+ try {
+ expect(() => resolveBundledSkillPath("hunk-extensions", [tempRoot])).toThrow(
+ "Could not locate the bundled Hunk hunk-extensions skill.",
+ );
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ test("locates a bundled skill through a nested hunkdiff package", () => {
const tempRoot = createTempRoot("hunk-skill-path-");
try {
@@ -60,7 +90,7 @@ describe("paths", () => {
writeFileSync(skillPath, "# skill\n");
writeFileSync(fakeBinary, "binary\n");
- expect(resolveBundledHunkReviewSkillPath([fakeBinary])).toBe(skillPath);
+ expect(resolveBundledSkillPath("hunk-review", [fakeBinary])).toBe(skillPath);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
diff --git a/src/core/paths.ts b/src/core/paths.ts
index 354a9a884..ce52be544 100644
--- a/src/core/paths.ts
+++ b/src/core/paths.ts
@@ -1,7 +1,32 @@
import fs from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
-const HUNK_REVIEW_SKILL_RELATIVE_PATH = join("skills", "hunk-review", "SKILL.md");
+/**
+ * Skills Hunk ships, in the order `hunk skill path` lists them.
+ *
+ * A skill is bundled only if it is in `package.json`'s `files` allowlist and the
+ * prebuilt artifact staging; `skills/` also holds maintainer-only documents that
+ * never ship, and naming them here would resolve paths users cannot have.
+ */
+export const BUNDLED_SKILL_NAMES = ["hunk-review", "hunk-extensions"] as const;
+export type BundledSkillName = (typeof BUNDLED_SKILL_NAMES)[number];
+
+/** The skill `hunk skill path` prints when the user names none. */
+export const DEFAULT_BUNDLED_SKILL_NAME: BundledSkillName = "hunk-review";
+
+/** Short aliases accepted alongside each skill's own name. */
+const BUNDLED_SKILL_ALIASES: Record = {
+ review: "hunk-review",
+ extensions: "hunk-extensions",
+};
+
+/** Resolve one user-supplied skill name, or nothing when it names no bundled skill. */
+export function resolveBundledSkillName(value: string): BundledSkillName | undefined {
+ const normalized = value.trim().toLowerCase();
+ return (
+ BUNDLED_SKILL_NAMES.find((name) => name === normalized) ?? BUNDLED_SKILL_ALIASES[normalized]
+ );
+}
/**
* Canonicalize one filesystem path, resolving through existing ancestors.
@@ -106,13 +131,22 @@ function findRelativePathFromAncestors(startPath: string, relativePath: string)
}
}
-/** Resolve the bundled Hunk review skill path from source, npm, or prebuilt package layouts. */
-export function resolveBundledHunkReviewSkillPath(searchRoots?: string[]) {
+/**
+ * Resolve one bundled skill's path from source, npm, or prebuilt package layouts.
+ *
+ * Every shipped skill lives at `skills//SKILL.md` in all three layouts, so
+ * the name is the only thing that varies and the search itself stays one walk.
+ */
+export function resolveBundledSkillPath(
+ name: BundledSkillName = DEFAULT_BUNDLED_SKILL_NAME,
+ searchRoots?: string[],
+) {
const roots = searchRoots ?? [import.meta.dir, process.execPath];
+ const skillRelativePath = join("skills", name, "SKILL.md");
const relativeCandidates = [
- HUNK_REVIEW_SKILL_RELATIVE_PATH,
- join("hunkdiff", HUNK_REVIEW_SKILL_RELATIVE_PATH),
- join("node_modules", "hunkdiff", HUNK_REVIEW_SKILL_RELATIVE_PATH),
+ skillRelativePath,
+ join("hunkdiff", skillRelativePath),
+ join("node_modules", "hunkdiff", skillRelativePath),
];
for (const root of roots) {
@@ -124,5 +158,5 @@ export function resolveBundledHunkReviewSkillPath(searchRoots?: string[]) {
}
}
- throw new Error("Could not locate the bundled Hunk review skill.");
+ throw new Error(`Could not locate the bundled Hunk ${name} skill.`);
}
diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md
index 3f6200aca..2142f2947 100644
--- a/website/src/content/docs/docs/extend/extensions.md
+++ b/website/src/content/docs/docs/extend/extensions.md
@@ -20,6 +20,8 @@ export default function (hunk: HunkExtensionAPI) {
What an extension can register is covered by the companion pages: the [extension API](/docs/extend/extension-api/), [file previews](/docs/extend/file-previews/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/).
+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.
+
## Where Hunk looks
| Group | Source | Runs |
diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md
index 4e12c77c9..28959b1fc 100644
--- a/website/src/content/docs/docs/reference/cli.md
+++ b/website/src/content/docs/docs/reference/cli.md
@@ -164,12 +164,12 @@ hunk markup guide
## `hunk skill path`
-print the bundled Hunk review skill path
+print a bundled Hunk skill path
### Usage
```bash
-hunk skill path
+hunk skill path [name]
```
## `hunk daemon serve`