Skip to content
5 changes: 5 additions & 0 deletions .changeset/heavy-moons-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Install shared extensions straight from git with `hunk extension install <owner>/<repo>[@ref]` (plus `list`, `update`, and `remove`), let extension manifests declare a minimum API version via `"hunk": {"apiVersion": N}`, and find community extensions under the `hunk-extension` GitHub topic.
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,22 @@ export default function (hunk: HunkExtensionAPI) {
}
```

Extensions shared as git repositories install straight from their host, and a
`hunk-extension` GitHub topic marks community ones:

```bash
hunk extension install acme/hunk-word-diff@v1.2.0 # or git:host/path, a URL, a local path
hunk extension list # then update [name] / remove <name>
```

Browse community extensions at
[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension);
publish yours by pushing the extension to a repository root and adding that
topic.

See [docs/extensions.md](docs/extensions.md) for the full API, the trust model,
and the `[extensions]` / `[extension.<id>]` config reference. Installable examples
include [review triage](examples/extensions/review-triage/), an optional
publishing guidance, and the `[extensions]` / `[extension.<id>]` config reference.
Installable examples include [review triage](examples/extensions/review-triage/), an optional
[rendered Markdown file view](examples/extensions/rendered-markdown/), and a
[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands.

Expand Down
84 changes: 84 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,28 @@ Because the manifest is a real `package.json`, a folder extension may depend on
npm packages: declare them, install them into the folder's own `node_modules`,
and imports resolve from the entry file the way they do in any other package.

The `hunk` field may also state the minimum extension API version the folder
needs:

```json
{
"name": "my-ext",
"version": "1.0.0",
"description": "What the extension does",
"hunk": { "extensions": ["./src/index.ts"], "apiVersion": 3 }
}
```

A Hunk whose extension API is older than `apiVersion` refuses the folder with a
startup notice naming the version it would need, instead of failing somewhere
inside the factory with whatever error the missing surface happens to produce.
Omit it while you only use surface that has been around a while; declare it when
you depend on something recent (the current version is exported as
`HUNK_EXTENSION_API_VERSION` from `hunkdiff/extension` and handed to factories
as `hunk.apiVersion`). The standard `name`, `version`, and `description` fields
are how tooling and humans identify a shared extension, so fill them in on
anything you publish.

Pointing `--extension` or `[extensions] paths` straight at a directory works
either way: a directory that is itself a folder extension loads as that one
extension, so its helper modules stay helpers. A directory that is not is
Expand Down Expand Up @@ -103,6 +125,68 @@ trust prompt, even when the path points inside the repository under review.
Never pass a path you have not read — including one copy-pasted from a
repository's own README.

## Sharing and installing extensions

Extensions are shared as plain git repositories — there is no registry to
publish to. `hunk extension install` clones one into a managed directory
(`~/.config/hunk/extensions/installed/<repo-name>/`), verifies it actually
contains an extension, installs its npm dependencies when it declares any, and
records the source and resolved commit:

```bash
hunk extension install acme/hunk-word-diff # GitHub shorthand
hunk extension install acme/hunk-word-diff@v1.2.0 # pin a tag, branch, or commit
hunk extension install git:codeberg.org/acme/ext # any host; https:// is assumed
hunk extension install https://github.com/acme/hunk-word-diff.git
hunk extension install ~/dev/hunk-word-diff # a local checkout, for testing
```

Managed installs load through the global source group — same origin, same
precedence, no trust prompt — because installing one is the explicit consent:
the install asks for confirmation (or `--yes`) after stating that extensions
run with your full user permissions. Only install repositories you trust.

`hunk extension list` shows every managed install with its version, commit, and
source. `hunk extension update [name]` re-clones one install (or all of them)
from its recorded source — an install pinned with `@ref` stays at that ref
until you re-install with a different one. `hunk extension remove <name>`
deletes the install and its record. Managed installs never collide with
extensions you copied into `~/.config/hunk/extensions/` by hand, and the
installer refuses to overwrite an unmanaged directory of the same name.

### Publishing an extension

A publishable extension repository is just the folder-extension layout at the
repository root:

```text
hunk-word-diff/
package.json # name, version, description, hunk field
index.ts # or entries declared in "hunk": {"extensions": [...]}
README.md
```

To publish one:

1. Give `package.json` a real `name`, `version`, and `description`, declare
entries under the `hunk` field, and state `"hunk": {"apiVersion": N}` if you
rely on recent API surface (see [the manifest](#where-hunk-looks-for-extensions)).
2. Keep it dependency-light. Declared `dependencies` are installed with
`bun install` at install time when the user has `bun` on PATH; without it
they get a warning and instructions. `react`, `@opentui/*`, and
`hunkdiff/extension` come from the host at runtime and belong in
`devDependencies` (types only), never `dependencies`.
3. Tag releases (`v1.2.0`) so users can pin with `@v1.2.0` instead of tracking
your default branch.
4. Push the repository to any git host and add the **`hunk-extension`** GitHub
topic so people can find it: every public repository with that topic shows
up at <https://github.com/topics/hunk-extension>.

Before publishing, exercise the exact layout users will install:
`hunk extension install /path/to/your/checkout` installs from a local
repository, and `hunk diff --extension /path/to/your/checkout` loads it for one
run without installing anything.

## Bundled extensions

Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension,
Expand Down
17 changes: 14 additions & 3 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,20 @@ 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.
`cp`. A `.hunk/extensions/` folder extension's `node_modules` has to exist on
every machine that loads it — keep a repo-shared extension dependency-free.

Shared extensions install from git with `hunk extension install <source>`
(`owner/repo[@ref]`, `git:host/path[@ref]`, a git URL, or a local path) into
`~/.config/hunk/extensions/installed/<repo-name>/`, where they load with global
origin; `list`, `update`, and `remove` manage them. Declared `dependencies` are
`bun install`ed at install time. The manifest may state
`{"hunk": {"apiVersion": N}}` — the minimum extension API version — and an older
Hunk refuses the extension with a startup notice instead of failing mid-factory.
To publish, push the folder-extension layout to a git repository's root with
real `name`/`version`/`description`, tag releases for `@ref` pins, and add the
`hunk-extension` GitHub topic so it appears at
<https://github.com/topics/hunk-extension>.

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
Expand Down
12 changes: 12 additions & 0 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import type { AppBootstrap } from "./types";
import type {
CliInput,
ExtensionManageCommandInput,
MarkupRenderCommandInput,
ParsedCliInput,
SessionCommandInput,
Expand Down Expand Up @@ -64,6 +65,10 @@ export type StartupPlan =
| {
kind: "markup-guide";
}
| {
kind: "extension-manage";
input: ExtensionManageCommandInput;
}
| {
kind: "app";
bootstrap: AppBootstrap;
Expand Down Expand Up @@ -163,6 +168,13 @@ export async function prepareStartupPlan(
};
}

if (parsedCliInput.kind === "extension-manage") {
return {
kind: "extension-manage",
input: parsedCliInput,
};
}

if (parsedCliInput.kind === "pager") {
const stdinText = await readStdinText();
const pagerOptions = parsedCliInput.options;
Expand Down
88 changes: 88 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1564,3 +1564,91 @@ describe("parseCli extension flags", () => {
expect(parsed.text).toContain("--no-extensions");
});
});

describe("parseCli extension management commands", () => {
test("parses install with its source and confirmation flag", async () => {
const parsed = await parseCli(["bun", "hunk", "extension", "install", "acme/hunk-ext@v1"]);
expect(parsed).toEqual({
kind: "extension-manage",
action: "install",
source: "acme/hunk-ext@v1",
yes: false,
});

const confirmed = await parseCli([
"bun",
"hunk",
"extension",
"install",
"acme/hunk-ext",
"--yes",
]);
expect(confirmed).toEqual({
kind: "extension-manage",
action: "install",
source: "acme/hunk-ext",
yes: true,
});
});

test("parses list, update, and remove with their targets", async () => {
expect(await parseCli(["bun", "hunk", "extension", "list"])).toEqual({
kind: "extension-manage",
action: "list",
});
expect(await parseCli(["bun", "hunk", "extension", "update"])).toEqual({
kind: "extension-manage",
action: "update",
name: undefined,
});
expect(await parseCli(["bun", "hunk", "extension", "update", "hunk-ext"])).toEqual({
kind: "extension-manage",
action: "update",
name: "hunk-ext",
});
expect(await parseCli(["bun", "hunk", "extension", "remove", "hunk-ext"])).toEqual({
kind: "extension-manage",
action: "remove",
name: "hunk-ext",
});
// Familiar spellings from other package managers resolve to remove.
expect(await parseCli(["bun", "hunk", "extension", "uninstall", "hunk-ext"])).toEqual({
kind: "extension-manage",
action: "remove",
name: "hunk-ext",
});
});

test("accepts ext as an alias for extension", async () => {
expect(await parseCli(["bun", "hunk", "ext", "list"])).toEqual({
kind: "extension-manage",
action: "list",
});
expect(await parseCli(["bun", "hunk", "ext", "install", "acme/hunk-ext", "--yes"])).toEqual({
kind: "extension-manage",
action: "install",
source: "acme/hunk-ext",
yes: true,
});
});

test("shows extension help for the bare command and rejects unknown subcommands", async () => {
const parsed = await parseCli(["bun", "hunk", "extension"]);
expect(parsed.kind).toBe("help");
if (parsed.kind === "help") {
expect(parsed.text).toContain("hunk extension install <source>");
expect(parsed.text).toContain("only install repositories you trust");
expect(parsed.text).toContain("hunk-extension");
}

expect(parseCli(["bun", "hunk", "extension", "publish"])).rejects.toThrow(
/Supported extension subcommands/,
);
});

test("session reload refuses to nest an extension management command", async () => {
expect(
parseCli(["bun", "hunk", "session", "reload", "abc123", "--", "extension", "list"]),
).rejects.toThrow(/review command/);
});
});
Loading
Loading