From a028f7eb03eb6a99a641609e56bcb16dfae242c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:23:24 +0000 Subject: [PATCH 1/5] docs(skills): add an extension-authoring skill for agents Agents asked to build a Hunk extension had no single entry point: the authoring guide, the published contract, the architecture map, and the example extensions all hold part of the answer, and nothing pointed at which touchpoint a given request needs. This adds a discovery-oriented skill that maps goals to API calls, states the invariants extensions most often break, and spells out how to verify one without taking over the user's terminal. The skill stays deliberately non-prescriptive about what to build and routes to docs/extensions.md, src/extension-api/types.ts, and examples/extensions/* rather than restating them. --- .changeset/extension-building-skill.md | 2 + AGENTS.md | 2 +- skills/hunk-extensions/SKILL.md | 201 +++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 .changeset/extension-building-skill.md create mode 100644 skills/hunk-extensions/SKILL.md diff --git a/.changeset/extension-building-skill.md b/.changeset/extension-building-skill.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/extension-building-skill.md @@ -0,0 +1,2 @@ +--- +--- 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/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md new file mode 100644 index 000000000..02c12da29 --- /dev/null +++ b/skills/hunk-extensions/SKILL.md @@ -0,0 +1,201 @@ +--- +name: hunk-extensions +description: Guides building extensions for Hunk (the terminal diff viewer) against the `hunkdiff/extension` API — themes, file languages, VCS backends, sidebar views, file views, commands, dialogs, workspace writes, changeset transforms, and lifecycle events. Use when asked to write, extend, debug, or review a Hunk extension, or when a request needs Hunk to show or do something it does not do out of the box. +--- + +# 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, worked examples. Start here. | +| `src/extension-api/types.ts` | The contract itself — exact field names, optionality, doc comments. | +| `examples/extensions/*` | Installable, working extensions. Copy patterns from these rather than invent. | +| `docs/extension-architecture.md` | Hunk's internals: which module owns what. 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 same guide lives at +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** | + +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.*`. +Folder extensions may depend on npm packages — install into the folder's own +`node_modules`. + +The **id** is the file stem (or folder name), and it is the namespace the +extension 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, or change how one is diffed | `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, notes, theme, reloads, exit | `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 handler and transform gets `ctx.cwd` and `ctx.notify(message, type?)`. +Beyond that: + +- **Event and bus handlers** also get `ctx.sidebars` (open/close/toggle any view) + and `ctx.events.emit`. +- **Command handlers** get `ctx.sidebars`, `ctx.fileViews` (select/toggle/refresh/ + enterMode), `ctx.selection` (a snapshot of file + hunk index at invocation), + `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 — 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)`. + +Public file/hunk data is always the frozen `ExtensionDiffFile` / +`ExtensionDiffHunk` shape; the opaque `metadata` is the renderer's parsed diff and +should only ever be passed through untouched. + +## Rules that bite + +Most extension bugs are one of these: + +- **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. +- **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; a throwing handler is a warning naming the extension. But + extensions run with full user permissions — that containment protects against + bugs, not against code that should not have been loaded. +- **`hunk.log` is collected as diagnostics, not printed** (the TUI owns the + screen). Use `ctx.notify` for anything a user should see, or write your own file. +- **Throw `HunkExtensionUserError`** (detected structurally by `name`) for + problems the user can fix — it prints the message plus `suggestions` with no + stack trace. + +## Verifying + +Hunk's TUI needs a real terminal, and the review UI is the user's — **do not +launch `hunk diff`/`hunk show` interactively to test.** 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`. +2. **Unit-test the logic.** Keep parsing, matching, and formatting in helper + modules with plain `bun test` coverage; keep the factory thin. +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 stay loaded either way). + +## If it does not load + +- No startup notice at all → discovery never saw the file. Check the directory, + the entry suffix, or the folder's `package.json` `hunk.extensions` paths. +- Notice naming the file → 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. +- 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. +- `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. +- `src/extensions/default/vcs/` loads from VCS adapter resolution and must stay + renderer-free. +- New API surface means updating `docs/extensions.md` (its examples are + typechecked as consumer code), `docs/extension-architecture.md` if ownership + moves, and adding a changeset with `bun run changeset`. From 5a888de96b21424f1cbd21572bd0f22d3024f2c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:04:49 +0000 Subject: [PATCH 2/5] docs(skills): warn that explicit extension paths run ungated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the repo-local group is trust-gated, so a skill that lists `--extension` beside the gated sources invites an agent to hand a user a path nobody read — the flag executes it with full user permissions even when it points inside the repository under review. Say so where the sources are listed, and tighten the frontmatter description and the sources table while nearby. --- skills/hunk-extensions/SKILL.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 02c12da29..904c7deb9 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -1,6 +1,6 @@ --- name: hunk-extensions -description: Guides building extensions for Hunk (the terminal diff viewer) against the `hunkdiff/extension` API — themes, file languages, VCS backends, sidebar views, file views, commands, dialogs, workspace writes, changeset transforms, and lifecycle events. Use when asked to write, extend, debug, or review a Hunk extension, or when a request needs Hunk to show or do something it does not do out of the box. +description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — themes, file languages, VCS backends, sidebar views, file views, commands, dialogs, workspace writes, changeset transforms, events. Use when writing, debugging, or reviewing a Hunk extension. --- # Building Hunk extensions @@ -24,13 +24,13 @@ material before writing code. ## Sources of truth — read before writing -| Source | What it answers | -| --------------------------------------- | ----------------------------------------------------------------------------- | -| `docs/extensions.md` | The authoring guide. Every call, every rule, worked examples. Start here. | -| `src/extension-api/types.ts` | The contract itself — exact field names, optionality, doc comments. | -| `examples/extensions/*` | Installable, working extensions. Copy patterns from these rather than invent. | -| `docs/extension-architecture.md` | Hunk's internals: which module owns what. Needed only when changing the host. | -| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | +| 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 same guide lives at and the contract ships as `node_modules/hunkdiff/dist/npm/extension/index.d.ts`. @@ -55,6 +55,12 @@ The examples, by what they demonstrate: | `~/.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.*`. From 92f1df6597ad2b670c4b2198f514c0b352144992 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:20:34 +0000 Subject: [PATCH 3/5] docs(skills): correct and complete the extension skill Two Opus 5 review passes over the skill found claims that were wrong and omissions that would predictably send an agent into a wall. Wrong: file-view `matches`/`layout` receive no context, so "every handler gets ctx" overstated it; `HunkExtensionUserError` only prints its suggestions from a VCS adapter operation, not from a command handler; the load notice names the extension rather than the file; `--no-extensions` spares the built-in sidebar too; the docs site splits the guide across five pages rather than mirroring `docs/extensions.md`. Missing: registering a sidebar or file view does not display it, which is the likeliest "my extension does nothing" outcome; reloads keep the factory but renumber file ids, so per-file state keyed by id silently dies; a rejected layout falls back to raw diff without saying so; there is no clipboard or stdout to write to; a standalone `.tsx` extension needs the OpenTUI jsxImportSource to typecheck; piping is not a headless escape hatch. Also notes that website extend pages are hand-written, so new API surface has to update them. --- skills/hunk-extensions/SKILL.md | 134 ++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 49 deletions(-) diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 904c7deb9..3162d085e 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -1,6 +1,6 @@ --- name: hunk-extensions -description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — themes, file languages, VCS backends, sidebar views, file views, commands, dialogs, workspace writes, changeset transforms, events. Use when writing, debugging, or reviewing a Hunk extension. +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 @@ -32,7 +32,9 @@ material before writing code. | `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 same guide lives at +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: @@ -63,9 +65,12 @@ 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.*`. -Folder extensions may depend on npm packages — install into the folder's own -`node_modules`. +`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 folder name), and it is the namespace the extension owns: commands are `.`, sidebar views `:`, @@ -75,42 +80,47 @@ 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, or change how one is diffed | `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, notes, theme, reloads, exit | `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` | +| 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 handler and transform gets `ctx.cwd` and `ctx.notify(message, type?)`. -Beyond that: +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 any view) - and `ctx.events.emit`. -- **Command handlers** get `ctx.sidebars`, `ctx.fileViews` (select/toggle/refresh/ - enterMode), `ctx.selection` (a snapshot of file + hunk index at invocation), +- **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 — see `ExtensionPaintTheme`), `keybindings` (ask by command - id, never hard-code a chord), and `actions` (`selectFile`, `selectHunk`, - `notify`). + `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`. Public file/hunk data is always the frozen `ExtensionDiffFile` / `ExtensionDiffHunk` shape; the opaque `metadata` is the renderer's parsed diff and @@ -120,6 +130,15 @@ should only ever be passed through untouched. 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`, @@ -129,7 +148,12 @@ Most extension bugs are one of these: `{ 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. + 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. @@ -145,26 +169,35 @@ Most extension bugs are one of these: - **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; a throwing handler is a warning naming the extension. But - extensions run with full user permissions — that containment protects against - bugs, not against code that should not have been loaded. -- **`hunk.log` is collected as diagnostics, not printed** (the TUI owns the - screen). Use `ctx.notify` for anything a user should see, or write your own file. -- **Throw `HunkExtensionUserError`** (detected structurally by `name`) for - problems the user can fix — it prints the message plus `suggestions` with no - stack trace. + 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` interactively to test.** Practical checks, in -order of cost: +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`. -2. **Unit-test the logic.** Keep parsing, matching, and formatting in helper - modules with plain `bun test` coverage; keep the factory thin. + add `hunkdiff` as a dev dependency and run `tsc --noEmit`; for a `.tsx` + extension also add `react`, `@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`. @@ -172,18 +205,20 @@ order of cost: 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 stay loaded either way). + (bundled VCS backends and the built-in sidebar stay loaded either way). ## If it does not load - No startup notice at all → discovery never saw the file. Check the directory, the entry suffix, or the folder's `package.json` `hunk.extensions` paths. -- Notice naming the file → id rejected (reserved, malformed, or already claimed), - import failure, missing default export, or a throwing factory. +- 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 `.`. @@ -195,13 +230,14 @@ 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. + 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. -- `src/extensions/default/vcs/` loads from VCS adapter resolution and must stay - renderer-free. - New API surface means updating `docs/extensions.md` (its examples are - typechecked as consumer code), `docs/extension-architecture.md` if ownership - moves, and adding a changeset with `bun run changeset`. + 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. From 3a93499ca0ec51a45a1d431e648f09e55ab87235 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 02:17:14 +0000 Subject: [PATCH 4/5] docs(skills): correct four overstated claims in the extension skill A silent startup is a successful load, not a discovery miss, so the troubleshooting entry read as a biconditional it never was. Transforms receive the live changeset rather than a frozen view, which the "always frozen" sentence flattened. A multi-entry manifest names each entry by its own stem instead of the folder. And React ships no declarations, so the standalone typecheck recipe needs @types/react or it fails on the first hook. --- skills/hunk-extensions/SKILL.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 3162d085e..e015b3ad2 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -72,11 +72,13 @@ dependencies, helper modules, or a README; a single file keeps the install to on exist on every machine that loads it — keep a repo-shared extension dependency-free. -The **id** is the file stem (or folder name), and it is the namespace the -extension 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. +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 @@ -122,9 +124,11 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's the routing decision, so kick off async work and report it later through `notify` or `refresh`. Escape is host-owned and never reaches `onKey`. -Public file/hunk data is always the frozen `ExtensionDiffFile` / -`ExtensionDiffHunk` shape; the opaque `metadata` is the renderer's parsed diff and -should only ever be passed through untouched. +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 @@ -192,8 +196,9 @@ 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`, `@opentui/core`, and `@opentui/react` as **dev** - dependencies and set `"jsx": "react-jsx"` with + 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 @@ -209,8 +214,9 @@ Practical checks, in order of cost: ## If it does not load -- No startup notice at all → discovery never saw the file. Check the directory, - the entry suffix, or the folder's `package.json` `hunk.extensions` paths. +- 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; From 2e88d78c306757355f3cb65e3357cbf76af70770 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 02:53:13 +0000 Subject: [PATCH 5/5] feat(cli): ship the extension-authoring skill and name it from skill path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension skill was checked in but reachable only from a source checkout, so the agents most likely to need it — working against an npm-installed Hunk — could not find it. Ship it the way the review skill ships, and give `hunk skill path` an optional name so a second bundled skill is addressable at all; a bare invocation still prints the review skill, since that is what existing docs and agent setups call. Naming the bundled set also makes prebuilt artifact staging copy those skills by name instead of the whole `skills/` directory, which had been sweeping in maintainer-only documents that reference scripts no artifact ships. --- .changeset/bundled-extensions-skill.md | 5 ++ .changeset/extension-building-skill.md | 2 - .github/workflows/release-prebuilt-npm.yml | 10 ++-- docs/extensions.md | 4 ++ package.json | 1 + scripts/build-prebuilt-artifact.test.ts | 36 +++++++++++--- scripts/build-prebuilt-artifact.ts | 17 +++++-- scripts/check-pack.ts | 5 +- scripts/check-prebuilt-pack.ts | 1 + scripts/smoke-prebuilt-install.ts | 25 ++++++---- src/core/cli.test.ts | 32 +++++++++++-- src/core/cli.ts | 45 ++++++++++++----- src/core/paths.test.ts | 40 ++++++++++++++-- src/core/paths.ts | 48 ++++++++++++++++--- .../content/docs/docs/extend/extensions.md | 2 + .../src/content/docs/docs/reference/cli.md | 4 +- 16 files changed, 218 insertions(+), 59 deletions(-) create mode 100644 .changeset/bundled-extensions-skill.md delete mode 100644 .changeset/extension-building-skill.md 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/.changeset/extension-building-skill.md b/.changeset/extension-building-skill.md deleted file mode 100644 index a845151cc..000000000 --- a/.changeset/extension-building-skill.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- 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/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/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`