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`