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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added `hunk skill path` to print the bundled Hunk review skill path for direct loading or symlinking in coding agents.

### Changed

- Show a one-time startup notice after version changes that points users with copied agent skills to `hunk skill path`.

### Fixed

## [0.9.1] - 2026-04-10
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ git diff --no-color | hunk patch - # review a patch from stdin

Load the [`skills/hunk-review/SKILL.md`](skills/hunk-review/SKILL.md) skill in your coding agent (e.g. Claude, Codex, Opencode, Pi).

Run `hunk skill path` to print the installed bundled skill path. Prefer loading or symlinking that file in your agent instead of copying it so Hunk upgrades stay in sync automatically.

Open Hunk in another window, then ask your agent to leave comments.

## Feature comparison
Expand Down Expand Up @@ -144,6 +146,7 @@ Hunk supports two agent workflows:
#### Steer a live Hunk window

Use the Hunk review skill: [`skills/hunk-review/SKILL.md`](skills/hunk-review/SKILL.md).
If you need the installed absolute path for your agent setup, run `hunk skill path`.

A good generic prompt is:

Expand Down
22 changes: 19 additions & 3 deletions bin/hunk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

function bundledSkillPath() {
return path.join(__dirname, "..", "skills", "hunk-review", "SKILL.md");
}

function ensureExecutable(target) {
if (process.platform === "win32") {
return;
Expand Down Expand Up @@ -94,21 +98,33 @@ function bundledBunRuntime() {
}
}

const forwardedArgs = process.argv.slice(2);
if (forwardedArgs.length === 2 && forwardedArgs[0] === "skill" && forwardedArgs[1] === "path") {
const skillPath = bundledSkillPath();
if (!fs.existsSync(skillPath)) {
console.error(`hunk: could not locate the bundled Hunk review skill at ${skillPath}`);
process.exit(1);
}

process.stdout.write(`${skillPath}\n`);
process.exit(0);
}

const overrideBinary = process.env.HUNK_BIN_PATH;
if (overrideBinary) {
run(overrideBinary, process.argv.slice(2));
run(overrideBinary, forwardedArgs);
}

const scriptDir = path.dirname(fs.realpathSync(__filename));
const prebuiltBinary = findInstalledBinary(scriptDir);
if (prebuiltBinary) {
run(prebuiltBinary, process.argv.slice(2));
run(prebuiltBinary, forwardedArgs);
}

const bunBinary = bundledBunRuntime();
if (bunBinary) {
const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js");
run(bunBinary, [entrypoint, ...process.argv.slice(2)]);
run(bunBinary, [entrypoint, ...forwardedArgs]);
}

const printablePackages = hostCandidates()
Expand Down
8 changes: 7 additions & 1 deletion scripts/check-prebuilt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ if (!existsSync(metaDir)) {
}

const metaPack = runPackDryRun(metaDir);
assertPaths(metaPack, ["bin/hunk.cjs", "README.md", "LICENSE", "package.json"]);
assertPaths(metaPack, [
"bin/hunk.cjs",
"skills/hunk-review/SKILL.md",
"README.md",
"LICENSE",
"package.json",
]);

const packageDirectories = readdirSync(releaseRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== "hunkdiff")
Expand Down
14 changes: 13 additions & 1 deletion scripts/smoke-prebuilt-install.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bun

import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers";

Expand Down Expand Up @@ -83,6 +83,18 @@ 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}`,
);
}

const bunCheck = Bun.spawnSync(
[
resolvedNode,
Expand Down
3 changes: 2 additions & 1 deletion scripts/stage-prebuilt-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function stageMetaPackage(
const metaDir = path.join(releaseRoot, rootPackage.name);
ensureDirectory(path.join(metaDir, "bin"));
cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs"));
cpSync(path.join(repoRoot, "skills"), path.join(metaDir, "skills"), { recursive: true });
cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md"));
cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE"));

Expand All @@ -87,7 +88,7 @@ function stageMetaPackage(
bin: {
hunk: "./bin/hunk.cjs",
},
files: ["bin", "README.md", "LICENSE"],
files: ["bin", "skills", "README.md", "LICENSE"],
keywords: rootPackage.keywords,
repository: rootPackage.repository,
homepage: rootPackage.homepage,
Expand Down
27 changes: 27 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("parseCli", () => {
expect(parsed.text).toContain("Usage:");
expect(parsed.text).toContain("hunk diff");
expect(parsed.text).toContain("hunk show");
expect(parsed.text).toContain("hunk skill path");
expect(parsed.text).toContain("Global options:");
expect(parsed.text).toContain("Common review options:");
expect(parsed.text).toContain("auto-reload when the current diff input changes");
Expand Down Expand Up @@ -188,6 +189,32 @@ describe("parseCli", () => {
});
});

test("prints the bundled skill path for hunk skill path", async () => {
const parsed = await parseCli(["bun", "hunk", "skill", "path"]);

expect(parsed.kind).toBe("help");
if (parsed.kind !== "help") {
throw new Error("Expected bundled skill path output.");
}

expect(parsed.text).toEndWith(`${join("skills", "hunk-review", "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",
"",
"Print the bundled Hunk review skill path.",
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
"",
].join("\n"),
});
});

test("parses the MCP daemon command", async () => {
const parsed = await parseCli(["bun", "hunk", "mcp", "serve"]);

Expand Down
51 changes: 51 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ParsedCliInput,
SessionCommentApplyItemInput,
} from "./types";
import { resolveBundledHunkReviewSkillPath } from "./paths";
import { resolveCliVersion } from "./version";

/** Validate one requested layout mode from CLI input. */
Expand Down Expand Up @@ -101,6 +102,22 @@ function renderCliVersion() {
return `${resolveCliVersion()}\n`;
}

/** Render the bundled Hunk review skill path for shell usage. */
function renderHunkReviewSkillPath() {
return `${resolveBundledHunkReviewSkillPath()}\n`;
}

/** Build the `hunk skill` help text. */
function renderSkillHelp() {
return [
"Usage: hunk skill path",
"",
"Print the bundled Hunk review skill path.",
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
"",
].join("\n");
}

/** Build the top-level help text shown by bare `hunk` and `hunk --help`. */
function renderCliHelp() {
return [
Expand All @@ -118,6 +135,7 @@ function renderCliHelp() {
" hunk pager general Git pager wrapper with diff detection",
" hunk difftool <left> <right> [path] review Git difftool file pairs",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk skill path print the bundled Hunk review skill path",
" hunk mcp serve run the local Hunk session daemon",
"",
"Global options:",
Expand Down Expand Up @@ -1141,6 +1159,37 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
throw new Error(`Unknown session command: ${subcommand}`);
}

/** Parse `hunk skill ...` for bundled skill discovery commands. */
async function parseSkillCommand(tokens: string[]): Promise<HelpCommandInput> {
const [subcommand, ...rest] = tokens;
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return {
kind: "help",
text: renderSkillHelp(),
};
}

if (subcommand !== "path") {
throw new Error("Only `hunk skill path` is supported.");
}

if (rest.includes("--help") || rest.includes("-h")) {
return {
kind: "help",
text: renderSkillHelp(),
};
}

if (rest.length > 0) {
throw new Error("`hunk skill path` does not accept additional arguments.");
}

return {
kind: "help",
text: renderHunkReviewSkillPath(),
};
}

/** Parse `hunk mcp serve` as the local daemon entrypoint. */
async function parseMcpCommand(tokens: string[]): Promise<ParsedCliInput> {
const [subcommand, ...rest] = tokens;
Expand Down Expand Up @@ -1258,6 +1307,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return parseStashCommand(rest, argv);
case "session":
return parseSessionCommand(rest);
case "skill":
return parseSkillCommand(rest);
case "mcp":
return parseMcpCommand(rest);
default:
Expand Down
16 changes: 2 additions & 14 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "node:fs";
import { dirname, join, resolve } from "node:path";
import { resolveGlobalConfigPath } from "./paths";
import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types";

const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
Expand Down Expand Up @@ -105,19 +106,6 @@ function findRepoRoot(cwd = process.cwd()) {
}
}

/** Resolve the global XDG-style config path, if the environment provides one. */
function globalConfigPath(env: NodeJS.ProcessEnv = process.env) {
if (env.XDG_CONFIG_HOME) {
return join(env.XDG_CONFIG_HOME, "hunk", "config.toml");
}

if (env.HOME) {
return join(env.HOME, ".config", "hunk", "config.toml");
}

return undefined;
}

/** Parse one TOML config file into a plain object. */
function readTomlRecord(path: string) {
if (!fs.existsSync(path)) {
Expand All @@ -139,7 +127,7 @@ export function resolveConfiguredCliInput(
): HunkConfigResolution {
const repoRoot = findRepoRoot(cwd);
const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined;
const userConfigPath = globalConfigPath(env);
const userConfigPath = resolveGlobalConfigPath(env);

let resolvedOptions: CommonOptions = {
mode: DEFAULT_VIEW_PREFERENCES.mode,
Expand Down
54 changes: 54 additions & 0 deletions src/core/paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
resolveBundledHunkReviewSkillPath,
resolveGlobalConfigPath,
resolveHunkStatePath,
} from "./paths";

function createTempRoot(prefix: string) {
return mkdtempSync(join(tmpdir(), prefix));
}

describe("paths", () => {
test("resolves XDG config and state paths", () => {
const env = { XDG_CONFIG_HOME: "/tmp/xdg-home" } as NodeJS.ProcessEnv;

expect(resolveGlobalConfigPath(env)).toBe("/tmp/xdg-home/hunk/config.toml");
expect(resolveHunkStatePath(env)).toBe("/tmp/xdg-home/hunk/state.json");
});

test("falls back to HOME for config and state paths", () => {
const env = { HOME: "/tmp/home" } as NodeJS.ProcessEnv;

expect(resolveGlobalConfigPath(env)).toBe("/tmp/home/.config/hunk/config.toml");
expect(resolveHunkStatePath(env)).toBe("/tmp/home/.config/hunk/state.json");
});

test("locates the bundled Hunk review skill from source", () => {
const resolvedPath = resolveBundledHunkReviewSkillPath([import.meta.dir]);

expect(resolvedPath).toEndWith(join("skills", "hunk-review", "SKILL.md"));
});

test("locates the bundled Hunk review skill through a nested hunkdiff package", () => {
const tempRoot = createTempRoot("hunk-skill-path-");

try {
const nestedPackageRoot = join(tempRoot, "node_modules", "hunkdiff");
const skillPath = join(nestedPackageRoot, "skills", "hunk-review", "SKILL.md");
const fakeBinary = join(tempRoot, "node_modules", "hunkdiff-linux-x64", "bin", "hunk");

mkdirSync(dirname(skillPath), { recursive: true });
mkdirSync(dirname(fakeBinary), { recursive: true });
writeFileSync(skillPath, "# skill\n");
writeFileSync(fakeBinary, "binary\n");

expect(resolveBundledHunkReviewSkillPath([fakeBinary])).toBe(skillPath);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
});
Loading
Loading