Skip to content

Commit 50ce0f7

Browse files
authored
feat(cli): help agents keep Hunk skills in sync (#190)
1 parent 792132b commit 50ce0f7

15 files changed

Lines changed: 600 additions & 100 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- Added `hunk skill path` to print the bundled Hunk review skill path for direct loading or symlinking in coding agents.
10+
911
### Changed
1012

13+
- Show a one-time startup notice after version changes that points users with copied agent skills to `hunk skill path`.
14+
1115
### Fixed
1216

1317
## [0.9.1] - 2026-04-10

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ git diff --no-color | hunk patch - # review a patch from stdin
6969

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

72+
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.
73+
7274
Open Hunk in another window, then ask your agent to leave comments.
7375

7476
## Feature comparison
@@ -144,6 +146,7 @@ Hunk supports two agent workflows:
144146
#### Steer a live Hunk window
145147

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

148151
A good generic prompt is:
149152

bin/hunk.cjs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ const fs = require("node:fs");
55
const os = require("node:os");
66
const path = require("node:path");
77

8+
function bundledSkillPath() {
9+
return path.join(__dirname, "..", "skills", "hunk-review", "SKILL.md");
10+
}
11+
812
function ensureExecutable(target) {
913
if (process.platform === "win32") {
1014
return;
@@ -94,21 +98,33 @@ function bundledBunRuntime() {
9498
}
9599
}
96100

101+
const forwardedArgs = process.argv.slice(2);
102+
if (forwardedArgs.length === 2 && forwardedArgs[0] === "skill" && forwardedArgs[1] === "path") {
103+
const skillPath = bundledSkillPath();
104+
if (!fs.existsSync(skillPath)) {
105+
console.error(`hunk: could not locate the bundled Hunk review skill at ${skillPath}`);
106+
process.exit(1);
107+
}
108+
109+
process.stdout.write(`${skillPath}\n`);
110+
process.exit(0);
111+
}
112+
97113
const overrideBinary = process.env.HUNK_BIN_PATH;
98114
if (overrideBinary) {
99-
run(overrideBinary, process.argv.slice(2));
115+
run(overrideBinary, forwardedArgs);
100116
}
101117

102118
const scriptDir = path.dirname(fs.realpathSync(__filename));
103119
const prebuiltBinary = findInstalledBinary(scriptDir);
104120
if (prebuiltBinary) {
105-
run(prebuiltBinary, process.argv.slice(2));
121+
run(prebuiltBinary, forwardedArgs);
106122
}
107123

108124
const bunBinary = bundledBunRuntime();
109125
if (bunBinary) {
110126
const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js");
111-
run(bunBinary, [entrypoint, ...process.argv.slice(2)]);
127+
run(bunBinary, [entrypoint, ...forwardedArgs]);
112128
}
113129

114130
const printablePackages = hostCandidates()

scripts/check-prebuilt-pack.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@ if (!existsSync(metaDir)) {
6363
}
6464

6565
const metaPack = runPackDryRun(metaDir);
66-
assertPaths(metaPack, ["bin/hunk.cjs", "README.md", "LICENSE", "package.json"]);
66+
assertPaths(metaPack, [
67+
"bin/hunk.cjs",
68+
"skills/hunk-review/SKILL.md",
69+
"README.md",
70+
"LICENSE",
71+
"package.json",
72+
]);
6773

6874
const packageDirectories = readdirSync(releaseRoot, { withFileTypes: true })
6975
.filter((entry) => entry.isDirectory() && entry.name !== "hunkdiff")

scripts/smoke-prebuilt-install.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22

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

@@ -83,6 +83,18 @@ try {
8383
);
8484
}
8585

86+
const skillPath = run([installedHunk, "skill", "path"], {
87+
env: commandEnv,
88+
}).stdout.trim();
89+
if (
90+
!skillPath.endsWith(path.join("skills", "hunk-review", "SKILL.md")) ||
91+
!existsSync(skillPath)
92+
) {
93+
throw new Error(
94+
`Expected installed hunk skill path to resolve to the bundled skill.\n${skillPath}`,
95+
);
96+
}
97+
8698
const bunCheck = Bun.spawnSync(
8799
[
88100
resolvedNode,

scripts/stage-prebuilt-npm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ function stageMetaPackage(
7777
const metaDir = path.join(releaseRoot, rootPackage.name);
7878
ensureDirectory(path.join(metaDir, "bin"));
7979
cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs"));
80+
cpSync(path.join(repoRoot, "skills"), path.join(metaDir, "skills"), { recursive: true });
8081
cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md"));
8182
cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE"));
8283

@@ -87,7 +88,7 @@ function stageMetaPackage(
8788
bin: {
8889
hunk: "./bin/hunk.cjs",
8990
},
90-
files: ["bin", "README.md", "LICENSE"],
91+
files: ["bin", "skills", "README.md", "LICENSE"],
9192
keywords: rootPackage.keywords,
9293
repository: rootPackage.repository,
9394
homepage: rootPackage.homepage,

src/core/cli.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe("parseCli", () => {
3434
expect(parsed.text).toContain("Usage:");
3535
expect(parsed.text).toContain("hunk diff");
3636
expect(parsed.text).toContain("hunk show");
37+
expect(parsed.text).toContain("hunk skill path");
3738
expect(parsed.text).toContain("Global options:");
3839
expect(parsed.text).toContain("Common review options:");
3940
expect(parsed.text).toContain("auto-reload when the current diff input changes");
@@ -188,6 +189,32 @@ describe("parseCli", () => {
188189
});
189190
});
190191

192+
test("prints the bundled skill path for hunk skill path", async () => {
193+
const parsed = await parseCli(["bun", "hunk", "skill", "path"]);
194+
195+
expect(parsed.kind).toBe("help");
196+
if (parsed.kind !== "help") {
197+
throw new Error("Expected bundled skill path output.");
198+
}
199+
200+
expect(parsed.text).toEndWith(`${join("skills", "hunk-review", "SKILL.md")}\n`);
201+
});
202+
203+
test("prints skill help for hunk skill --help", async () => {
204+
const parsed = await parseCli(["bun", "hunk", "skill", "--help"]);
205+
206+
expect(parsed).toEqual({
207+
kind: "help",
208+
text: [
209+
"Usage: hunk skill path",
210+
"",
211+
"Print the bundled Hunk review skill path.",
212+
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
213+
"",
214+
].join("\n"),
215+
});
216+
});
217+
191218
test("parses the MCP daemon command", async () => {
192219
const parsed = await parseCli(["bun", "hunk", "mcp", "serve"]);
193220

src/core/cli.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
ParsedCliInput,
1111
SessionCommentApplyItemInput,
1212
} from "./types";
13+
import { resolveBundledHunkReviewSkillPath } from "./paths";
1314
import { resolveCliVersion } from "./version";
1415

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

105+
/** Render the bundled Hunk review skill path for shell usage. */
106+
function renderHunkReviewSkillPath() {
107+
return `${resolveBundledHunkReviewSkillPath()}\n`;
108+
}
109+
110+
/** Build the `hunk skill` help text. */
111+
function renderSkillHelp() {
112+
return [
113+
"Usage: hunk skill path",
114+
"",
115+
"Print the bundled Hunk review skill path.",
116+
"Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.",
117+
"",
118+
].join("\n");
119+
}
120+
104121
/** Build the top-level help text shown by bare `hunk` and `hunk --help`. */
105122
function renderCliHelp() {
106123
return [
@@ -118,6 +135,7 @@ function renderCliHelp() {
118135
" hunk pager general Git pager wrapper with diff detection",
119136
" hunk difftool <left> <right> [path] review Git difftool file pairs",
120137
" hunk session <subcommand> inspect or control a live Hunk session",
138+
" hunk skill path print the bundled Hunk review skill path",
121139
" hunk mcp serve run the local Hunk session daemon",
122140
"",
123141
"Global options:",
@@ -1141,6 +1159,37 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
11411159
throw new Error(`Unknown session command: ${subcommand}`);
11421160
}
11431161

1162+
/** Parse `hunk skill ...` for bundled skill discovery commands. */
1163+
async function parseSkillCommand(tokens: string[]): Promise<HelpCommandInput> {
1164+
const [subcommand, ...rest] = tokens;
1165+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1166+
return {
1167+
kind: "help",
1168+
text: renderSkillHelp(),
1169+
};
1170+
}
1171+
1172+
if (subcommand !== "path") {
1173+
throw new Error("Only `hunk skill path` is supported.");
1174+
}
1175+
1176+
if (rest.includes("--help") || rest.includes("-h")) {
1177+
return {
1178+
kind: "help",
1179+
text: renderSkillHelp(),
1180+
};
1181+
}
1182+
1183+
if (rest.length > 0) {
1184+
throw new Error("`hunk skill path` does not accept additional arguments.");
1185+
}
1186+
1187+
return {
1188+
kind: "help",
1189+
text: renderHunkReviewSkillPath(),
1190+
};
1191+
}
1192+
11441193
/** Parse `hunk mcp serve` as the local daemon entrypoint. */
11451194
async function parseMcpCommand(tokens: string[]): Promise<ParsedCliInput> {
11461195
const [subcommand, ...rest] = tokens;
@@ -1258,6 +1307,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
12581307
return parseStashCommand(rest, argv);
12591308
case "session":
12601309
return parseSessionCommand(rest);
1310+
case "skill":
1311+
return parseSkillCommand(rest);
12611312
case "mcp":
12621313
return parseMcpCommand(rest);
12631314
default:

src/core/config.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs";
22
import { dirname, join, resolve } from "node:path";
3+
import { resolveGlobalConfigPath } from "./paths";
34
import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types";
45

56
const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
@@ -105,19 +106,6 @@ function findRepoRoot(cwd = process.cwd()) {
105106
}
106107
}
107108

108-
/** Resolve the global XDG-style config path, if the environment provides one. */
109-
function globalConfigPath(env: NodeJS.ProcessEnv = process.env) {
110-
if (env.XDG_CONFIG_HOME) {
111-
return join(env.XDG_CONFIG_HOME, "hunk", "config.toml");
112-
}
113-
114-
if (env.HOME) {
115-
return join(env.HOME, ".config", "hunk", "config.toml");
116-
}
117-
118-
return undefined;
119-
}
120-
121109
/** Parse one TOML config file into a plain object. */
122110
function readTomlRecord(path: string) {
123111
if (!fs.existsSync(path)) {
@@ -139,7 +127,7 @@ export function resolveConfiguredCliInput(
139127
): HunkConfigResolution {
140128
const repoRoot = findRepoRoot(cwd);
141129
const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined;
142-
const userConfigPath = globalConfigPath(env);
130+
const userConfigPath = resolveGlobalConfigPath(env);
143131

144132
let resolvedOptions: CommonOptions = {
145133
mode: DEFAULT_VIEW_PREFERENCES.mode,

src/core/paths.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import {
6+
resolveBundledHunkReviewSkillPath,
7+
resolveGlobalConfigPath,
8+
resolveHunkStatePath,
9+
} from "./paths";
10+
11+
function createTempRoot(prefix: string) {
12+
return mkdtempSync(join(tmpdir(), prefix));
13+
}
14+
15+
describe("paths", () => {
16+
test("resolves XDG config and state paths", () => {
17+
const env = { XDG_CONFIG_HOME: "/tmp/xdg-home" } as NodeJS.ProcessEnv;
18+
19+
expect(resolveGlobalConfigPath(env)).toBe("/tmp/xdg-home/hunk/config.toml");
20+
expect(resolveHunkStatePath(env)).toBe("/tmp/xdg-home/hunk/state.json");
21+
});
22+
23+
test("falls back to HOME for config and state paths", () => {
24+
const env = { HOME: "/tmp/home" } as NodeJS.ProcessEnv;
25+
26+
expect(resolveGlobalConfigPath(env)).toBe("/tmp/home/.config/hunk/config.toml");
27+
expect(resolveHunkStatePath(env)).toBe("/tmp/home/.config/hunk/state.json");
28+
});
29+
30+
test("locates the bundled Hunk review skill from source", () => {
31+
const resolvedPath = resolveBundledHunkReviewSkillPath([import.meta.dir]);
32+
33+
expect(resolvedPath).toEndWith(join("skills", "hunk-review", "SKILL.md"));
34+
});
35+
36+
test("locates the bundled Hunk review skill through a nested hunkdiff package", () => {
37+
const tempRoot = createTempRoot("hunk-skill-path-");
38+
39+
try {
40+
const nestedPackageRoot = join(tempRoot, "node_modules", "hunkdiff");
41+
const skillPath = join(nestedPackageRoot, "skills", "hunk-review", "SKILL.md");
42+
const fakeBinary = join(tempRoot, "node_modules", "hunkdiff-linux-x64", "bin", "hunk");
43+
44+
mkdirSync(dirname(skillPath), { recursive: true });
45+
mkdirSync(dirname(fakeBinary), { recursive: true });
46+
writeFileSync(skillPath, "# skill\n");
47+
writeFileSync(fakeBinary, "binary\n");
48+
49+
expect(resolveBundledHunkReviewSkillPath([fakeBinary])).toBe(skillPath);
50+
} finally {
51+
rmSync(tempRoot, { recursive: true, force: true });
52+
}
53+
});
54+
});

0 commit comments

Comments
 (0)