Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bundled-extensions-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Ship an extension-authoring skill for coding agents and let `hunk skill path [name]` print any bundled skill.
10 changes: 6 additions & 4 deletions .github/workflows/release-prebuilt-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"bin",
"dist/npm",
"skills/hunk-review",
"skills/hunk-extensions",
"README.md",
"LICENSE"
],
Expand Down
36 changes: 30 additions & 6 deletions scripts/build-prebuilt-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 };
}
Expand All @@ -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");

Expand All @@ -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);
Expand Down
17 changes: 12 additions & 5 deletions scripts/build-prebuilt-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-prebuilt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 15 additions & 10 deletions scripts/smoke-prebuilt-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading