From 95671b1893dd81f4e0004ce84096e8ac701ba996 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 22:20:32 +0000 Subject: [PATCH 1/4] feat(cli): add `hunk skill install --agent` and `hunk skill show` `hunk skill install --agent ` writes a thin pointer SKILL.md into a coding agent's skills directory (Claude Code, Codex, opencode, Cursor, Amp, GitHub Copilot, or the shared `.agents/skills` convention). The pointer carries only the bundled skill's name and description and tells the agent to run the new `hunk skill show`, so the installed Hunk binary stays the single owner of the skill text and upgrades never leave a stale copy behind. - `--agent` repeats for several hosts; shared directories are written once. - `--project` targets the current directory instead of the home directory. - Hunk rewrites its own earlier pointer but refuses a hand-written SKILL.md unless `--force` is passed, and checks every target before writing any. - Docs, README, agent workflow guide, and the generated CLI reference now lead with the install command. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018PqAUQp8FqVcDxcMHXV3ci --- .changeset/light-agent-skills.md | 5 + README.md | 8 +- docs/agent-workflows.md | 18 +- packages/hunk/src/app/cli.test.ts | 83 ++++- packages/hunk/src/app/cli.ts | 167 +++++++-- packages/hunk/src/app/startup.ts | 12 + .../hunk/src/core/install/agentSkills.test.ts | 207 ++++++++++++ packages/hunk/src/core/install/agentSkills.ts | 318 ++++++++++++++++++ packages/hunk/src/core/run/commandInputs.ts | 15 + packages/hunk/src/core/run/paths.ts | 8 + .../src/hunk-review/skillDocument.test.ts | 5 +- packages/hunk/src/main.tsx | 9 + .../content/docs/docs/agents/review-skill.md | 32 +- .../docs/docs/agents/review-with-an-agent.md | 12 +- .../content/docs/docs/extend/extensions.md | 2 +- .../src/content/docs/docs/reference/cli.md | 34 ++ .../content/docs/docs/start/quick-start.md | 2 +- 17 files changed, 879 insertions(+), 58 deletions(-) create mode 100644 .changeset/light-agent-skills.md create mode 100644 packages/hunk/src/core/install/agentSkills.test.ts create mode 100644 packages/hunk/src/core/install/agentSkills.ts diff --git a/.changeset/light-agent-skills.md b/.changeset/light-agent-skills.md new file mode 100644 index 000000000..1ebc4e37a --- /dev/null +++ b/.changeset/light-agent-skills.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add `hunk skill install --agent ` to teach a coding agent how to drive Hunk in one command. It writes a short pointer `SKILL.md` into the skills directory of Claude Code, Codex, opencode, Cursor, Amp, GitHub Copilot, or any tool reading `.agents/skills`; repeat `--agent` for several, and add `--project` to install into the current repository. The pointer keeps only the skill's name and description and loads the rest through the new `hunk skill show [name]`, so it stays current across Hunk upgrades instead of going stale as a copy. diff --git a/README.md b/README.md index f89abd0d7..0a2386f9b 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,13 @@ Watch mode remains continuous. Direct-file and Git-backed reviews normally use f ### Working with agents 1. Open Hunk in another terminal with `hunk diff` or `hunk show`. -2. Tell your agent to add the skill file returned by `hunk skill path`. -3. Ask your agent to use the skill against the live Hunk session. +2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). +3. Ask your agent to review the live Hunk session. -A good generic prompt is: +The installed skill is a short pointer that loads its instructions from `hunk skill show`, so it stays current across Hunk upgrades. Without an installed skill, a good generic prompt is: ```text -Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +Run `hunk skill show` and follow that skill to review the live Hunk session. ``` For the full live-session and `--agent-context` workflow guide, see [the agent workflow guide](https://github.com/modem-dev/hunk/blob/main/docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index a6bdd9cd8..00952c8f8 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -8,13 +8,23 @@ Use Hunk with agents in two ways: ## Recommended workflow: steer a live Hunk window 1. Open Hunk in one terminal with a normal review command such as `hunk diff` or `hunk show`. -2. Load the Hunk review skill: [`packages/hunk/skills/hunk-review/SKILL.md`](../packages/hunk/skills/hunk-review/SKILL.md). -3. Ask the agent to use the skill and review the current session. +2. Install the Hunk review skill into your agent once: -A good generic prompt is: + ```bash + hunk skill install --agent claude # or codex, opencode, cursor, amp, copilot, agents + ``` + +3. Ask the agent to review the current Hunk session. + +The installed file is a short pointer that carries the skill's name and description and loads the +rest with `hunk skill show`, so it never goes stale when Hunk upgrades. Repeat `--agent` to install +into several agents, or add `--project` to write it into the current repository instead of your +home directory. Agents that load or symlink files directly can use `hunk skill path` instead. + +Without an installed skill, a good generic prompt is: ```text -Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +Run `hunk skill show` and follow that skill to review the live Hunk session. ``` That skill teaches the agent how to inspect a live Hunk session, navigate it, reload it, and leave inline comments. diff --git a/packages/hunk/src/app/cli.test.ts b/packages/hunk/src/app/cli.test.ts index 856bf88d5..d7d8ee17b 100644 --- a/packages/hunk/src/app/cli.test.ts +++ b/packages/hunk/src/app/cli.test.ts @@ -9,6 +9,8 @@ import { parseCli, WATCH_OPTION, } from "./cli"; +import { AGENT_SKILL_HOST_IDS } from "../core/install/agentSkills"; +import { readBundledSkillDocument } from "../core/run/paths"; import { resolveCliVersion } from "../core/run/version"; const tempDirs: string[] = []; @@ -653,22 +655,71 @@ describe("parseCli", () => { 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 [name]", - "", - "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"), + expect(parsed.kind).toBe("help"); + if (parsed.kind !== "help") { + throw new Error("Expected skill help output."); + } + expect(parsed.text).toContain( + "hunk skill install --agent [skill] [--project] [--force]", + ); + expect(parsed.text).toContain("hunk skill show [skill]"); + expect(parsed.text).toContain("hunk skill path [skill]"); + expect(parsed.text).toContain(`Agents: ${AGENT_SKILL_HOST_IDS.join(", ")}.`); + expect(parsed.text).toContain(` hunk-review (default, "review")`); + }); + + test("prints the bundled skill text for hunk skill show", async () => { + const parsed = await parseCli(["bun", "hunk", "skill", "show"]); + expect(parsed).toEqual({ kind: "help", text: readBundledSkillDocument("hunk-review") }); + + const named = await parseCli(["bun", "hunk", "skill", "show", "extensions"]); + expect(named).toEqual({ kind: "help", text: readBundledSkillDocument("hunk-extensions") }); + }); + + test("parses hunk skill install into agents, scope, and skill", async () => { + expect(await parseCli(["bun", "hunk", "skill", "install", "--agent", "claude"])).toEqual({ + kind: "skill-install", + skill: "hunk-review", + agents: ["claude"], + scope: "user", + force: false, + }); + + expect( + await parseCli([ + "bun", + "hunk", + "skill", + "install", + "--agent", + "Claude-Code", + "--agent", + "codex", + "--project", + "--force", + "extensions", + ]), + ).toEqual({ + kind: "skill-install", + skill: "hunk-extensions", + agents: ["claude", "codex"], + scope: "project", + force: true, }); }); + test("rejects hunk skill install without a known agent", async () => { + await expect(parseCli(["bun", "hunk", "skill", "install"])).rejects.toThrow( + "`hunk skill install` requires --agent .", + ); + await expect(parseCli(["bun", "hunk", "skill", "install", "--agent", "aider"])).rejects.toThrow( + 'Unknown agent "aider". Agents are `claude`, `codex`', + ); + await expect( + parseCli(["bun", "hunk", "skill", "install", "--agent", "claude", "bogus"]), + ).rejects.toThrow('Unknown skill "bogus".'); + }); + test("parses the daemon serve command", async () => { const parsed = await parseCli(["bun", "hunk", "daemon", "serve"]); @@ -1906,8 +1957,10 @@ describe("parseCli command help text", () => { test("renders skill help for both `skill --help` and `skill path --help`", async () => { const bare = await expectHelp(["skill", "--help"]); - expect(bare).toContain("Usage: hunk skill path"); + expect(bare).toContain("hunk skill path [skill]"); expect(await expectHelp(["skill", "path", "--help"])).toBe(bare); + expect(await expectHelp(["skill", "show", "--help"])).toBe(bare); + expect(await expectHelp(["skill", "install", "--help"])).toBe(bare); }); test("renders the comment overview and per-comment-subcommand help", async () => { @@ -2057,7 +2110,7 @@ describe("parseCli argument validation", () => { test("rejects unknown skill, daemon, stash, and comment subcommands", async () => { await expect(parseCli(["bun", "hunk", "skill", "bogus"])).rejects.toThrow( - "Only `hunk skill path` is supported.", + "Supported skill subcommands are install, show, and path.", ); await expect(parseCli(["bun", "hunk", "skill", "path", "bogus"])).rejects.toThrow( 'Unknown skill "bogus". Bundled skills are hunk-review and hunk-extensions.', diff --git a/packages/hunk/src/app/cli.ts b/packages/hunk/src/app/cli.ts index f5ed164a7..8a624099b 100644 --- a/packages/hunk/src/app/cli.ts +++ b/packages/hunk/src/app/cli.ts @@ -13,6 +13,7 @@ import { type PagerCommandInput, type ParsedCliInput, type SelfUpdateCommandInput, + type SkillInstallCommandInput, type SessionCommentListType, type SessionCommentApplyItemInput, } from "../core/run/commandInputs"; @@ -25,8 +26,15 @@ import { parseUpdateVersion, UPDATE_METHOD_VALUES, } from "../core/install/selfUpdate"; +import { + AGENT_SKILL_HOST_IDS, + listAgentSkillHostIds, + resolveAgentSkillHost, +} from "../core/install/agentSkills"; import { BUNDLED_SKILL_NAMES, + DEFAULT_BUNDLED_SKILL_NAME, + readBundledSkillDocument, resolveBundledSkillName, resolveBundledSkillPath, type BundledSkillName, @@ -304,6 +312,35 @@ export const CLI_REFERENCE_COMMANDS = { summary: "print a bundled Hunk skill path", synopsis: ["hunk skill path [name]"], }, + "skill-show": { + path: "skill show", + summary: "print a bundled Hunk skill", + synopsis: ["hunk skill show [name]"], + details: [ + "Prints the full SKILL.md shipped with the installed Hunk version. Pointer skills written by `hunk skill install` run this command to load their instructions, so the text always matches the installed CLI.", + ], + }, + "skill-install": { + path: "skill install", + summary: "install a pointer skill into a coding agent's skills directory", + synopsis: ["hunk skill install --agent [name] [--project] [--force]"], + details: [ + "Writes a short SKILL.md that carries the bundled skill's name and description and tells the agent to run `hunk skill show` for the instructions. Nothing else is copied, so the installed skill never goes stale when Hunk upgrades.", + "Agents: `claude` (Claude Code), `codex`, `opencode`, `cursor`, `amp`, `copilot` (GitHub Copilot), and `agents` for any tool that reads the shared `.agents/skills` convention.", + ], + options: [ + { + flag: "--agent ", + description: "coding agent to install into; repeat for several", + parse: "collect", + }, + { + flag: "--project", + description: "install under the current directory instead of the home directory", + }, + { flag: "--force", description: "replace an existing SKILL.md Hunk did not generate" }, + ], + }, "extension-install": { path: "extension install", summary: "install a shared extension from a git repository", @@ -554,15 +591,30 @@ function renderBundledSkillPath(name?: BundledSkillName) { /** Build the `hunk skill` help text. */ function renderSkillHelp() { return [ - "Usage: hunk skill path [name]", + "Usage:", + " hunk skill install --agent [skill] [--project] [--force]", + " hunk skill show [skill]", + " hunk skill path [skill]", + "", + "Teach a coding agent how to drive Hunk.", "", - "Print a bundled Hunk skill path.", - "Load or symlink that file in your coding agent to keep it in sync across Hunk upgrades.", + "install write a pointer SKILL.md into the agent's skills directory. The pointer keeps", + " only the skill's name and description and loads the rest with `hunk skill show`,", + " so it stays current across Hunk upgrades. Repeat --agent to install into several.", + ` Agents: ${AGENT_SKILL_HOST_IDS.join(", ")}.`, + " --project writes under the current directory instead of your home directory.", + "show print the bundled skill for agents that load instructions on demand.", + "path print the bundled skill's path for agents that load or symlink files.", "", "Skills:", ` hunk-review (default, "review") review a live Hunk session with \`hunk session\` commands`, ` hunk-extensions ("extensions") build extensions against the hunkdiff/extension API`, "", + "Examples:", + " hunk skill install --agent claude", + " hunk skill install --agent codex --agent cursor --project", + " hunk skill show hunk-extensions", + "", ].join("\n"); } @@ -587,6 +639,8 @@ 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 install --agent teach a coding agent to drive Hunk", + " hunk skill show [name] print a bundled Hunk skill", " hunk skill path [name] print a bundled Hunk skill path", " hunk extension install and manage shared extensions", " hunk update [version] update Hunk with the package manager that installed it", @@ -1186,7 +1240,8 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "extension-manage" || input.kind === "extension-cli" || input.kind === "history" || - input.kind === "update" + input.kind === "update" || + input.kind === "skill-install" ) { throw new Error( "Session reload requires a Hunk review command after --, such as `diff` or `show`.", @@ -1917,8 +1972,81 @@ async function parseMarkupCommand(tokens: string[]): Promise { throw new Error("Supported markup subcommands are render and guide."); } -/** Parse `hunk skill ...` for bundled skill discovery commands. */ -async function parseSkillCommand(tokens: string[]): Promise { +/** Resolve the optional bundled skill name given to a `hunk skill` subcommand. */ +function resolveSkillCommandName(subcommand: string, rest: string[]) { + if (rest.length > 1) { + throw new Error(`\`hunk skill ${subcommand}\` accepts at most one skill name.`); + } + + const [requestedName] = rest; + if (requestedName === undefined) { + return DEFAULT_BUNDLED_SKILL_NAME; + } + + const name = resolveBundledSkillName(requestedName); + if (!name) { + throw new Error( + `Unknown skill "${requestedName}". Bundled skills are ${BUNDLED_SKILL_NAMES.join(" and ")}.`, + ); + } + return name; +} + +/** Parse `hunk skill install ...` into the agents, scope, and skill to write. */ +async function parseSkillInstallCommand( + tokens: string[], +): Promise { + const command = createCliReferenceCommand("skill-install").argument( + "[skill]", + "bundled skill to install; hunk-review when omitted", + ); + + let parsedSkill: string | undefined; + let parsedOptions: { agent?: string[]; project?: boolean; force?: boolean } = {}; + command.action( + ( + skill: string | undefined, + options: { agent?: string[]; project?: boolean; force?: boolean }, + ) => { + parsedSkill = skill; + parsedOptions = options; + }, + ); + + if (tokens.includes("--help") || tokens.includes("-h")) { + return { kind: "help", text: renderSkillHelp() }; + } + + await parseStandaloneCommand(command, tokens); + + const requestedAgents = parsedOptions.agent ?? []; + if (requestedAgents.length === 0) { + throw new Error( + `\`hunk skill install\` requires --agent . Agents are ${listAgentSkillHostIds()}.`, + ); + } + + const agents = requestedAgents.map((value) => { + const host = resolveAgentSkillHost(value); + if (!host) { + throw new Error(`Unknown agent "${value}". Agents are ${listAgentSkillHostIds()}.`); + } + return host.id; + }); + + return { + kind: "skill-install", + skill: resolveSkillCommandName("install", parsedSkill === undefined ? [] : [parsedSkill]), + agents, + scope: parsedOptions.project ? "project" : "user", + force: parsedOptions.force ?? false, + }; +} + +/** Parse `hunk skill ...`: install a pointer skill, or print a bundled skill's text or path. */ +async function parseSkillCommand( + tokens: string[], +): Promise { const [subcommand, ...rest] = tokens; if (!subcommand || subcommand === "--help" || subcommand === "-h") { return { @@ -1927,8 +2055,12 @@ async function parseSkillCommand(tokens: string[]): Promise { }; } - if (subcommand !== "path") { - throw new Error("Only `hunk skill path` is supported."); + if (subcommand === "install") { + return parseSkillInstallCommand(rest); + } + + if (subcommand !== "path" && subcommand !== "show") { + throw new Error("Supported skill subcommands are install, show, and path."); } if (rest.includes("--help") || rest.includes("-h")) { @@ -1938,25 +2070,10 @@ async function parseSkillCommand(tokens: string[]): Promise { }; } - 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 ")}.`, - ); - } - + const name = resolveSkillCommandName(subcommand, rest); return { kind: "help", - text: renderBundledSkillPath(name), + text: subcommand === "show" ? readBundledSkillDocument(name) : renderBundledSkillPath(name), }; } diff --git a/packages/hunk/src/app/startup.ts b/packages/hunk/src/app/startup.ts index cdcc28068..5314d1e38 100644 --- a/packages/hunk/src/app/startup.ts +++ b/packages/hunk/src/app/startup.ts @@ -23,6 +23,7 @@ import type { ParsedCliInput, SelfUpdateCommandInput, SessionCommandInput, + SkillInstallCommandInput, } from "../core/run/commandInputs"; import { canReloadInput } from "../core/run/inputReload"; import { assertReliableWatchRuntime } from "../core/watch/runtime"; @@ -99,6 +100,10 @@ export type StartupPlan = kind: "self-update"; input: SelfUpdateCommandInput; } + | { + kind: "skill-install"; + input: SkillInstallCommandInput; + } | { kind: "extension-cli-exit"; exitCode: number; @@ -416,6 +421,13 @@ export async function prepareStartupPlan( }); } + if (parsedCliInput.kind === "skill-install") { + return await finishHeadlessPlan({ + kind: "skill-install", + input: parsedCliInput, + }); + } + if (parsedCliInput.kind === "history") { const baseVcsCatalog = await loadBaseVcsCatalog(); const { loadHistoryBootstrap } = await import("./historyBootstrap"); diff --git a/packages/hunk/src/core/install/agentSkills.test.ts b/packages/hunk/src/core/install/agentSkills.test.ts new file mode 100644 index 000000000..d12df6cea --- /dev/null +++ b/packages/hunk/src/core/install/agentSkills.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { readBundledSkillDocument } from "../run/paths"; +import { + AGENT_SKILL_HOST_IDS, + AGENT_SKILL_HOSTS, + AGENT_SKILL_STUB_MARKER, + parseSkillFrontmatter, + planAgentSkillTargets, + renderAgentSkillStub, + resolveAgentSkillHost, + runAgentSkillInstallCommand, + type AgentSkillInstallInput, +} from "./agentSkills"; + +const HOME = join("/", "home", "reviewer"); +const CWD = join("/", "work", "repo"); + +const REVIEW_SKILL = [ + "---", + "name: hunk-review", + "description: Drives a live Hunk session: navigates, comments, reloads.", + "---", + "", + "# Full instructions that must never be copied", +].join("\n"); + +/** Run one install against an in-memory tree, returning output and every file written. */ +async function runInstall( + input: Partial, + options: { existing?: Record; env?: NodeJS.ProcessEnv } = {}, +) { + const stdout: string[] = []; + const files = new Map(Object.entries(options.existing ?? {})); + const written: string[] = []; + let exitCode: number | undefined; + let error: unknown; + try { + exitCode = await runAgentSkillInstallCommand( + { skill: "hunk-review", agents: ["claude"], scope: "user", force: false, ...input }, + { + stdout: (text) => stdout.push(text), + env: options.env ?? { HOME }, + cwd: CWD, + readBundledSkill: () => REVIEW_SKILL, + readFile: (path) => files.get(path), + writeFile: (path, text) => { + files.set(path, text); + written.push(path); + }, + }, + ); + } catch (caught) { + error = caught; + } + return { exitCode, error, stdout: stdout.join(""), files, written }; +} + +describe("agent skill hosts", () => { + test("resolves ids and aliases case-insensitively", () => { + expect(resolveAgentSkillHost("claude")?.id).toBe("claude"); + expect(resolveAgentSkillHost("Claude-Code")?.id).toBe("claude"); + expect(resolveAgentSkillHost(" GitHub-Copilot ")?.id).toBe("copilot"); + expect(resolveAgentSkillHost("aider")).toBeUndefined(); + expect(AGENT_SKILL_HOST_IDS).toEqual(AGENT_SKILL_HOSTS.map((host) => host.id)); + }); + + test("honors each agent's home override before falling back to the home directory", () => { + const context = { + env: { + HOME, + CLAUDE_CONFIG_DIR: join("/", "cfg", "claude"), + CODEX_HOME: join("/", "cfg", "codex"), + }, + home: HOME, + cwd: CWD, + }; + const targets = planAgentSkillTargets( + { skill: "hunk-review", agents: ["claude", "codex", "opencode", "cursor"], scope: "user" }, + context, + ); + + expect(targets.map((target) => target.path)).toEqual([ + join("/", "cfg", "claude", "skills", "hunk-review", "SKILL.md"), + join("/", "cfg", "codex", "skills", "hunk-review", "SKILL.md"), + join(HOME, ".config", "opencode", "skills", "hunk-review", "SKILL.md"), + join(HOME, ".cursor", "skills", "hunk-review", "SKILL.md"), + ]); + }); + + test("routes XDG-style hosts through XDG_CONFIG_HOME like Hunk's own config", () => { + const xdg = join("/", "xdg"); + const targets = planAgentSkillTargets( + { skill: "hunk-review", agents: ["opencode", "amp"], scope: "user" }, + { env: { HOME, XDG_CONFIG_HOME: xdg }, home: HOME, cwd: CWD }, + ); + + expect(targets.map((target) => target.path)).toEqual([ + join(xdg, "opencode", "skills", "hunk-review", "SKILL.md"), + join(xdg, "agents", "skills", "hunk-review", "SKILL.md"), + ]); + }); + + test("writes project-scoped skills under the current directory and dedupes shared directories", () => { + const targets = planAgentSkillTargets( + { skill: "hunk-extensions", agents: ["codex", "amp", "agents", "copilot"], scope: "project" }, + { env: { HOME }, home: HOME, cwd: CWD }, + ); + + // Codex, Amp, and the generic host all read `.agents/skills`, so that pointer is written once. + expect(targets.map((target) => [target.host.id, target.path])).toEqual([ + ["codex", join(CWD, ".agents", "skills", "hunk-extensions", "SKILL.md")], + ["copilot", join(CWD, ".github", "skills", "hunk-extensions", "SKILL.md")], + ]); + }); +}); + +describe("pointer skill rendering", () => { + test("copies name and description from the bundled frontmatter", () => { + expect(parseSkillFrontmatter(REVIEW_SKILL)).toEqual({ + name: "hunk-review", + description: "Drives a live Hunk session: navigates, comments, reloads.", + }); + expect(parseSkillFrontmatter(REVIEW_SKILL.replaceAll("\n", "\r\n"))).toEqual( + parseSkillFrontmatter(REVIEW_SKILL), + ); + }); + + test("rejects bundled documents without usable frontmatter", () => { + expect(() => parseSkillFrontmatter("# no frontmatter")).toThrow("missing its frontmatter"); + expect(() => parseSkillFrontmatter("---\nname: x\n---\n")).toThrow( + "must declare both name and description", + ); + }); + + test("renders a pointer that defers to `hunk skill show` and carries the regeneration marker", () => { + const stub = renderAgentSkillStub(parseSkillFrontmatter(REVIEW_SKILL)); + + expect(stub.startsWith("---\nname: hunk-review\ndescription: Drives a live Hunk session")).toBe( + true, + ); + expect(stub).toContain(AGENT_SKILL_STUB_MARKER); + expect(stub).toContain("hunk skill show hunk-review"); + expect(stub).not.toContain("Full instructions that must never be copied"); + }); + + test("parses the real bundled skills so the pointer never ships blank triggers", () => { + for (const name of ["hunk-review", "hunk-extensions"] as const) { + const frontmatter = parseSkillFrontmatter(readBundledSkillDocument(name)); + expect(frontmatter.name).toBe(name); + expect(frontmatter.description.length).toBeGreaterThan(20); + } + }); +}); + +describe("runAgentSkillInstallCommand", () => { + test("writes one pointer per agent and reports each path", async () => { + const { exitCode, stdout, files, written } = await runInstall({ + agents: ["claude", "cursor"], + }); + + expect(exitCode).toBe(0); + expect(written).toEqual([ + join(HOME, ".claude", "skills", "hunk-review", "SKILL.md"), + join(HOME, ".cursor", "skills", "hunk-review", "SKILL.md"), + ]); + for (const path of written) { + expect(files.get(path)).toContain("hunk skill show hunk-review"); + } + expect(stdout).toContain("Installed hunk-review for Claude Code"); + expect(stdout).toContain("Installed hunk-review for Cursor"); + expect(stdout).toContain("stays current across Hunk upgrades"); + }); + + test("overwrites its own earlier pointer without --force", async () => { + const path = join(HOME, ".claude", "skills", "hunk-review", "SKILL.md"); + const { exitCode, written } = await runInstall( + {}, + { existing: { [path]: `---\nname: hunk-review\n---\n${AGENT_SKILL_STUB_MARKER}\nold body` } }, + ); + + expect(exitCode).toBe(0); + expect(written).toEqual([path]); + }); + + test("refuses to replace a hand-written skill unless forced, and writes nothing", async () => { + const claudePath = join(HOME, ".claude", "skills", "hunk-review", "SKILL.md"); + const cursorPath = join(HOME, ".cursor", "skills", "hunk-review", "SKILL.md"); + const existing = { [cursorPath]: "# my own hunk skill" }; + + const refused = await runInstall({ agents: ["claude", "cursor"] }, { existing }); + expect(String(refused.error)).toContain("already exists and was not generated by Hunk"); + // The refusal happens before any write, so the Claude pointer is not half-installed. + expect(refused.written).toEqual([]); + + const forced = await runInstall({ agents: ["claude", "cursor"], force: true }, { existing }); + expect(forced.exitCode).toBe(0); + expect(forced.written).toEqual([claudePath, cursorPath]); + }); + + test("rejects unknown agents by name", async () => { + const { error } = await runInstall({ + agents: ["aider" as unknown as AgentSkillInstallInput["agents"][number]], + }); + expect(String(error)).toContain('Unknown agent "aider"'); + }); +}); diff --git a/packages/hunk/src/core/install/agentSkills.ts b/packages/hunk/src/core/install/agentSkills.ts new file mode 100644 index 000000000..1d5feb42c --- /dev/null +++ b/packages/hunk/src/core/install/agentSkills.ts @@ -0,0 +1,318 @@ +import fs from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { HunkUserError } from "../run/errors"; +import { + type BundledSkillName, + readBundledSkillDocument, + resolveUserConfigDir, +} from "../run/paths"; + +/** + * Runs `hunk skill install`: writes a thin pointer skill into a coding agent's skill directory. + * + * The installed file is deliberately not a copy of the bundled skill. It carries only the + * frontmatter an agent needs to decide when to load it (`name` and `description`, read from the + * bundled document at install time) and a body that tells the agent to run `hunk skill show` for + * the real instructions. The installed Hunk binary therefore stays the single owner of the skill + * text, and upgrading Hunk never leaves a stale copy behind in an agent's directory. + * + * Every agent host is one row in `AGENT_SKILL_HOSTS`: where it reads user-wide skills and where + * it reads repository skills. Filesystem access, the environment, the home directory, and the + * bundled skill text all arrive through `AgentSkillInstallIo` so tests run against a scratch tree. + */ + +/** Marker every generated pointer skill carries so reinstalls can tell their own files apart. */ +export const AGENT_SKILL_STUB_MARKER = ""; + +/** Inputs a host needs to resolve its user-wide skills directory. */ +interface AgentSkillHostContext { + env: NodeJS.ProcessEnv; + home: string; +} + +/** One coding agent Hunk can install a pointer skill for. */ +export interface AgentSkillHost { + /** Value accepted by `--agent`. */ + readonly id: string; + /** Product name shown in output and docs. */ + readonly label: string; + /** Other spellings `--agent` accepts for this host. */ + readonly aliases?: readonly string[]; + /** Directory holding `/SKILL.md` for every project on this machine. */ + readonly userSkillsDir: (context: AgentSkillHostContext) => string; + /** Directory, relative to a repository root, holding that repository's skills. */ + readonly projectSkillsDir: string; +} + +/** Resolve `$XDG_CONFIG_HOME`-style config roots the way Hunk resolves its own. */ +function configDir(context: AgentSkillHostContext) { + return resolveUserConfigDir(context.env) ?? join(context.home, ".config"); +} + +/** + * Agents Hunk knows how to install into, in the order `--help` lists them. + * + * Several hosts also read the shared `.agents/skills` convention, so `agents` installs there once + * for any agent that follows it instead of writing one copy per product. + */ +export const AGENT_SKILL_HOSTS = [ + { + id: "claude", + label: "Claude Code", + aliases: ["claude-code"], + userSkillsDir: ({ env, home }) => + join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "skills"), + projectSkillsDir: join(".claude", "skills"), + }, + { + id: "codex", + label: "Codex", + userSkillsDir: ({ env, home }) => join(env.CODEX_HOME || join(home, ".codex"), "skills"), + projectSkillsDir: join(".agents", "skills"), + }, + { + id: "opencode", + label: "opencode", + userSkillsDir: (context) => join(configDir(context), "opencode", "skills"), + projectSkillsDir: join(".opencode", "skills"), + }, + { + id: "cursor", + label: "Cursor", + userSkillsDir: ({ home }) => join(home, ".cursor", "skills"), + projectSkillsDir: join(".cursor", "skills"), + }, + { + id: "amp", + label: "Amp", + userSkillsDir: (context) => join(configDir(context), "agents", "skills"), + projectSkillsDir: join(".agents", "skills"), + }, + { + id: "copilot", + label: "GitHub Copilot", + aliases: ["github-copilot"], + userSkillsDir: ({ home }) => join(home, ".copilot", "skills"), + projectSkillsDir: join(".github", "skills"), + }, + { + id: "agents", + label: "any agent reading .agents/skills", + userSkillsDir: ({ home }) => join(home, ".agents", "skills"), + projectSkillsDir: join(".agents", "skills"), + }, +] as const satisfies readonly AgentSkillHost[]; + +export type AgentSkillHostId = (typeof AGENT_SKILL_HOSTS)[number]["id"]; + +/** `--agent` values, in the order help and error messages list them. */ +export const AGENT_SKILL_HOST_IDS: readonly AgentSkillHostId[] = AGENT_SKILL_HOSTS.map( + (host) => host.id, +); + +/** One known host, typed by its literal id so callers can carry the id through command inputs. */ +export type ResolvedAgentSkillHost = AgentSkillHost & { readonly id: AgentSkillHostId }; + +/** Resolve one user-supplied `--agent` value, or nothing when it names no known host. */ +export function resolveAgentSkillHost(value: string): ResolvedAgentSkillHost | undefined { + const normalized = value.trim().toLowerCase(); + return (AGENT_SKILL_HOSTS as readonly ResolvedAgentSkillHost[]).find( + (host) => host.id === normalized || host.aliases?.includes(normalized), + ); +} + +/** Join ids into the "`a`, `b`, and `c`" phrasing the error messages use. */ +export function listAgentSkillHostIds() { + const quoted = AGENT_SKILL_HOST_IDS.map((id) => `\`${id}\``); + return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`; +} + +/** Whether the pointer lands in the user's home or the current repository. */ +export type AgentSkillScope = "user" | "project"; + +export interface AgentSkillInstallInput { + /** Bundled skill the pointer loads. */ + skill: BundledSkillName; + /** Hosts to install into; at least one. */ + agents: AgentSkillHostId[]; + scope: AgentSkillScope; + /** Replace an existing SKILL.md that Hunk did not generate. */ + force: boolean; +} + +/** Frontmatter fields the pointer copies so the agent triggers on the same conditions. */ +export interface SkillFrontmatter { + name: string; + description: string; +} + +/** + * Read `name` and `description` from a SKILL.md frontmatter block. + * + * Bundled skills keep both values on one line each, so a line-oriented scan is enough; a document + * missing either field is a packaging bug and fails loudly rather than installing a blank pointer. + */ +export function parseSkillFrontmatter(document: string): SkillFrontmatter { + const lines = document.replaceAll("\r\n", "\n").split("\n"); + if (lines[0] !== "---") { + throw new Error("Bundled skill is missing its frontmatter block."); + } + + const fields: Partial = {}; + for (const line of lines.slice(1)) { + if (line === "---") { + break; + } + const separator = line.indexOf(":"); + if (separator === -1) { + continue; + } + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key === "name" || key === "description") { + fields[key] = value; + } + } + + if (!fields.name || !fields.description) { + throw new Error("Bundled skill frontmatter must declare both name and description."); + } + + return { name: fields.name, description: fields.description }; +} + +/** + * Render the pointer SKILL.md for one bundled skill. + * + * The body stays short on purpose: agents load it only after the frontmatter matched, and its one + * job is to hand off to the installed CLI, which renders the full instructions for its own version. + */ +export function renderAgentSkillStub(frontmatter: SkillFrontmatter) { + return [ + "---", + `name: ${frontmatter.name}`, + `description: ${frontmatter.description}`, + "---", + "", + AGENT_SKILL_STUB_MARKER, + "", + `# ${frontmatter.name}`, + "", + "This file is a pointer. The instructions live in the installed `hunk` CLI so they always match its version.", + "", + "Before anything else, load them and follow them:", + "", + "```bash", + `hunk skill show ${frontmatter.name}`, + "```", + "", + "If `hunk` is not on PATH, ask the user to install Hunk first: https://hunk.dev/docs/start/install/", + "", + ].join("\n"); +} + +/** Filesystem, environment, and output seams `hunk skill install` runs through. */ +export interface AgentSkillInstallIo { + stdout: (text: string) => void; + env?: NodeJS.ProcessEnv; + cwd?: string; + home?: string; + /** Bundled skill text; defaults to the document shipped beside this binary. */ + readBundledSkill?: (name: BundledSkillName) => string; + readFile?: (path: string) => string | undefined; + writeFile?: (path: string, text: string) => void; +} + +/** One pointer file the command decided to write, before touching the filesystem. */ +export interface AgentSkillInstallTarget { + host: AgentSkillHost; + path: string; +} + +/** Resolve where each requested host keeps skills for the requested scope. */ +export function planAgentSkillTargets( + input: Pick, + context: AgentSkillHostContext & { cwd: string }, +): AgentSkillInstallTarget[] { + const targets: AgentSkillInstallTarget[] = []; + const seen = new Set(); + for (const id of input.agents) { + const host = resolveAgentSkillHost(id); + if (!host) { + throw new HunkUserError(`Unknown agent "${id}". Agents are ${listAgentSkillHostIds()}.`); + } + const skillsDir = + input.scope === "project" + ? join(context.cwd, host.projectSkillsDir) + : host.userSkillsDir(context); + const path = join(skillsDir, input.skill, "SKILL.md"); + // Codex, Amp, and the generic host share `.agents/skills`; write that pointer once. + if (seen.has(path)) { + continue; + } + seen.add(path); + targets.push({ host, path }); + } + return targets; +} + +/** Read one file, or nothing when it does not exist. */ +function readFileIfPresent(path: string) { + try { + return fs.readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +/** Write one file, creating its directories. */ +function writeFileWithDirs(path: string, text: string) { + fs.mkdirSync(dirname(path), { recursive: true }); + fs.writeFileSync(path, text); +} + +/** Resolve the home directory from the environment first so tests and sandboxes can redirect it. */ +function resolveHome(env: NodeJS.ProcessEnv, fallback?: string) { + return env.HOME || env.USERPROFILE || fallback || homedir(); +} + +/** Install pointer skills for every requested agent and report each path written. */ +export async function runAgentSkillInstallCommand( + input: AgentSkillInstallInput, + io: AgentSkillInstallIo, +): Promise { + const env = io.env ?? process.env; + const cwd = io.cwd ?? process.cwd(); + const home = resolveHome(env, io.home); + const readBundledSkill = io.readBundledSkill ?? readBundledSkillDocument; + const readFile = io.readFile ?? readFileIfPresent; + const writeFile = io.writeFile ?? writeFileWithDirs; + + const frontmatter = parseSkillFrontmatter(readBundledSkill(input.skill)); + const stub = renderAgentSkillStub(frontmatter); + const targets = planAgentSkillTargets(input, { env, home, cwd }); + + // Check every target before writing any, so a refusal leaves no half-installed set behind. + for (const target of targets) { + const existing = readFile(target.path); + if (existing !== undefined && !existing.includes(AGENT_SKILL_STUB_MARKER) && !input.force) { + throw new HunkUserError(`${target.path} already exists and was not generated by Hunk.`, [ + "Re-run with --force to replace it, or remove the file first.", + ]); + } + } + + for (const target of targets) { + writeFile(target.path, stub); + io.stdout(`Installed ${frontmatter.name} for ${target.host.label}\n ${target.path}\n`); + } + + io.stdout( + `\nThe skill loads its instructions from \`hunk skill show ${frontmatter.name}\`, so it stays current across Hunk upgrades.\n`, + ); + return 0; +} diff --git a/packages/hunk/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts index 8892f7e90..1d5f28cdb 100644 --- a/packages/hunk/src/core/run/commandInputs.ts +++ b/packages/hunk/src/core/run/commandInputs.ts @@ -13,7 +13,9 @@ import type { ExtensionVcsShowInput, ExtensionVcsStashShowInput, } from "../../extension-api/types"; +import type { AgentSkillHostId, AgentSkillScope } from "../install/agentSkills"; import type { InstallSource } from "../install/installSource"; +import type { BundledSkillName } from "./paths"; export type LayoutMode = "auto" | "split" | "unified"; export type LayoutModeInput = LayoutMode | "stack"; @@ -399,6 +401,18 @@ export interface ExtensionCliInvocationInput { extensionsEnabled: boolean; } +export interface SkillInstallCommandInput { + kind: "skill-install"; + /** Bundled skill the installed pointer loads through `hunk skill show`. */ + skill: BundledSkillName; + /** Coding agents to install into, as given to `--agent`; at least one. */ + agents: AgentSkillHostId[]; + /** `user` writes under the home directory; `project` writes under the current directory. */ + scope: AgentSkillScope; + /** Replace an existing SKILL.md Hunk did not generate. */ + force: boolean; +} + export interface SelfUpdateCommandInput { kind: "update"; /** Version to install; the install channel's newest release when omitted. */ @@ -427,4 +441,5 @@ export type ParsedCliInput = | MarkupGuideCommandInput | ExtensionManageCommandInput | ExtensionCliInvocationInput + | SkillInstallCommandInput | SelfUpdateCommandInput; diff --git a/packages/hunk/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts index 33f61f850..c098f193c 100644 --- a/packages/hunk/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -174,3 +174,11 @@ export function resolveBundledSkillPath( throw new Error(`Could not locate the bundled Hunk ${name} skill.`); } + +/** Read one bundled skill's full SKILL.md text, the document `hunk skill show` prints. */ +export function readBundledSkillDocument( + name: BundledSkillName = DEFAULT_BUNDLED_SKILL_NAME, + searchRoots?: string[], +) { + return fs.readFileSync(resolveBundledSkillPath(name, searchRoots), "utf8"); +} diff --git a/packages/hunk/src/hunk-review/skillDocument.test.ts b/packages/hunk/src/hunk-review/skillDocument.test.ts index ac5eded55..d1c0b529f 100644 --- a/packages/hunk/src/hunk-review/skillDocument.test.ts +++ b/packages/hunk/src/hunk-review/skillDocument.test.ts @@ -28,6 +28,9 @@ const DOCUMENTED_AGENT_FLAGS = new Set([ /** Flags of non-hunk shell tools that appear inside doc examples (e.g. curl). */ const NON_HUNK_SHELL_FLAGS = new Set(["--data"]); +/** Flags of `hunk skill install`, which the workflow doc uses to hand agents the skill. */ +const SKILL_INSTALL_FLAGS = new Set(["--agent", "--project", "--force"]); + /** Normalize checkout line endings so the comparison stays portable on Windows. */ function normalizeNewlines(text: string) { return text.replaceAll("\r\n", "\n"); @@ -61,7 +64,7 @@ describe("hunk-review skill document", () => { expect(mentioned.length).toBeGreaterThan(0); for (const flag of mentioned) { - if (NON_HUNK_SHELL_FLAGS.has(flag)) { + if (NON_HUNK_SHELL_FLAGS.has(flag) || SKILL_INSTALL_FLAGS.has(flag)) { continue; } expect(DOCUMENTED_AGENT_FLAGS).toContain(flag); diff --git a/packages/hunk/src/main.tsx b/packages/hunk/src/main.tsx index 80a6caff0..2dc5b68da 100644 --- a/packages/hunk/src/main.tsx +++ b/packages/hunk/src/main.tsx @@ -71,6 +71,15 @@ async function main() { ); } + if (startupPlan.kind === "skill-install") { + const { runAgentSkillInstallCommand } = await import("./core/install/agentSkills"); + process.exit( + await runAgentSkillInstallCommand(startupPlan.input, { + stdout: (text) => writeStdout(text), + }), + ); + } + if (startupPlan.kind === "markup-guide") { const { runMarkupGuideCommand } = await import("./ui/lib/stml/cli"); process.exit(runMarkupGuideCommand({ stdout: (text) => writeStdout(text) })); diff --git a/website/src/content/docs/docs/agents/review-skill.md b/website/src/content/docs/docs/agents/review-skill.md index 46968aea3..819459353 100644 --- a/website/src/content/docs/docs/agents/review-skill.md +++ b/website/src/content/docs/docs/agents/review-skill.md @@ -5,13 +5,39 @@ description: Load the versioned machine guidance that teaches coding agents Hunk Hunk ships a generated `hunk-review` skill with every installation. It is the authoritative machine-facing workflow for session selection, efficient review inspection, navigation, reloads, and comments. -## Locate the installed skill +## Install it into your agent ```bash -hunk skill path +hunk skill install --agent claude +hunk skill install --agent codex --agent cursor --project ``` -Load or symlink the returned file according to your coding agent's skill mechanism. Resolve the path again after upgrades so the guidance stays aligned with the installed CLI. +| `--agent` | Reads user skills from | Reads project skills from | +| ---------- | ------------------------------------------ | ------------------------- | +| `claude` | `$CLAUDE_CONFIG_DIR` or `~/.claude/skills` | `.claude/skills` | +| `codex` | `$CODEX_HOME` or `~/.codex/skills` | `.agents/skills` | +| `opencode` | `~/.config/opencode/skills` | `.opencode/skills` | +| `cursor` | `~/.cursor/skills` | `.cursor/skills` | +| `amp` | `~/.config/agents/skills` | `.agents/skills` | +| `copilot` | `~/.copilot/skills` | `.github/skills` | +| `agents` | `~/.agents/skills` | `.agents/skills` | + +`~/.config` follows `$XDG_CONFIG_HOME` when it is set. `--project` writes under the current directory; the default writes under your home directory. Hunk rewrites a pointer it generated earlier, but refuses to replace a hand-written `SKILL.md` unless you pass `--force`. + +The installed file is deliberately light. It carries the skill's `name` and `description` so the agent knows when to use it, and its body tells the agent to run: + +```bash +hunk skill show +``` + +That prints the full skill shipped with the installed Hunk version, so the agent always sees instructions that match the CLI on the machine, and upgrading Hunk never requires reinstalling the skill. + +## Locate or read the installed skill + +```bash +hunk skill show # print the full skill +hunk skill path # print its path, for agents that load or symlink files +``` For agents that need a stable web-readable URL, use the [generated Hunk review skill](/docs/hunk-review-skill.md). The published artifact and installed skill are rendered by the same function; neither is a handwritten copy. diff --git a/website/src/content/docs/docs/agents/review-with-an-agent.md b/website/src/content/docs/docs/agents/review-with-an-agent.md index fffd0f23c..51ddf2bc0 100644 --- a/website/src/content/docs/docs/agents/review-with-an-agent.md +++ b/website/src/content/docs/docs/agents/review-with-an-agent.md @@ -15,16 +15,20 @@ Keep that window open. Normal Hunk sessions register with a local loopback daemo ## Give the agent the skill -In the agent's shell, locate the skill bundled with the installed Hunk version: +Install the bundled review skill into your agent once: ```bash -hunk skill path +hunk skill install --agent claude ``` -Ask the agent to load that file and use it for the review. A portable prompt is: +`--agent` accepts `claude`, `codex`, `opencode`, `cursor`, `amp`, `copilot`, and `agents` for any tool that reads the shared `.agents/skills` convention. Repeat it to install into several agents at once, or add `--project` to write the skill into the current repository instead of your home directory. + +The installed file is a short pointer, not a copy. It keeps the skill's name and description so the agent knows when to use it, and loads the instructions with `hunk skill show`, so upgrading Hunk never leaves a stale skill behind. See [Hunk review skill](/docs/agents/review-skill/) for the details. + +Then ask the agent to review the session. Without an installed skill, a portable prompt is: ```text -Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +Run `hunk skill show` and follow that skill to review the live Hunk session. ``` The skill tells agents not to launch the interactive TUI themselves. It teaches them to use the session surface instead. diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 61aaab3dc..fb6f7171f 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -20,7 +20,7 @@ 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 panes](/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. +Writing one with a coding agent? `hunk skill install --agent hunk-extensions` installs a bundled skill that maps these touchpoints for agents, the way the default `hunk skill install` does for reviewing. ## Where Hunk looks diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 87dd1bd63..ad649809c 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -183,6 +183,40 @@ print a bundled Hunk skill path hunk skill path [name] ``` +## `hunk skill show` + +print a bundled Hunk skill + +### Usage + +```bash +hunk skill show [name] +``` + +Prints the full SKILL.md shipped with the installed Hunk version. Pointer skills written by `hunk skill install` run this command to load their instructions, so the text always matches the installed CLI. + +## `hunk skill install` + +install a pointer skill into a coding agent's skills directory + +### Usage + +```bash +hunk skill install --agent [name] [--project] [--force] +``` + +Writes a short SKILL.md that carries the bundled skill's name and description and tells the agent to run `hunk skill show` for the instructions. Nothing else is copied, so the installed skill never goes stale when Hunk upgrades. + +Agents: `claude` (Claude Code), `codex`, `opencode`, `cursor`, `amp`, `copilot` (GitHub Copilot), and `agents` for any tool that reads the shared `.agents/skills` convention. + +### Command-specific options + +| Option | Description | +| ---------------- | ----------------------------------------------------------------- | +| `--agent ` | coding agent to install into; repeat for several | +| `--project` | install under the current directory instead of the home directory | +| `--force` | replace an existing SKILL.md Hunk did not generate | + ## `hunk extension install` install a shared extension from a git repository diff --git a/website/src/content/docs/docs/start/quick-start.md b/website/src/content/docs/docs/start/quick-start.md index 27a514334..3b32cd1cd 100644 --- a/website/src/content/docs/docs/start/quick-start.md +++ b/website/src/content/docs/docs/start/quick-start.md @@ -49,4 +49,4 @@ Hunk reloads file- and Git-backed input while preserving the review experience. ## Bring in an agent -Keep Hunk open, then in another terminal ask your coding agent to run `hunk skill path` and use the returned review skill. Continue with [Review with an agent](/docs/agents/review-with-an-agent/). +Keep Hunk open, install the review skill into your agent once with `hunk skill install --agent claude` (or `codex`, `opencode`, `cursor`, `amp`, `copilot`), then ask the agent to review the live session. Continue with [Review with an agent](/docs/agents/review-with-an-agent/). From f5079595d00ba5697dde56d063a4795e1f370638 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 02:10:56 +0000 Subject: [PATCH 2/4] docs(npm): sync the package README with the repository copy `check:pack` requires packages/hunk/README.md to match the root README, which now leads the agent section with `hunk skill install`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018PqAUQp8FqVcDxcMHXV3ci --- packages/hunk/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hunk/README.md b/packages/hunk/README.md index f89abd0d7..0a2386f9b 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -124,13 +124,13 @@ Watch mode remains continuous. Direct-file and Git-backed reviews normally use f ### Working with agents 1. Open Hunk in another terminal with `hunk diff` or `hunk show`. -2. Tell your agent to add the skill file returned by `hunk skill path`. -3. Ask your agent to use the skill against the live Hunk session. +2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). +3. Ask your agent to review the live Hunk session. -A good generic prompt is: +The installed skill is a short pointer that loads its instructions from `hunk skill show`, so it stays current across Hunk upgrades. Without an installed skill, a good generic prompt is: ```text -Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +Run `hunk skill show` and follow that skill to review the live Hunk session. ``` For the full live-session and `--agent-context` workflow guide, see [the agent workflow guide](https://github.com/modem-dev/hunk/blob/main/docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. From aecb501c32a88e3d946d8b5ac8d6e315fcf4b44f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 02:13:49 +0000 Subject: [PATCH 3/4] fix(skill): refuse symlinked project targets and check exact pointer shape `hunk skill install --project` writes under repository content, so a checkout could redirect `.claude/skills` (or the SKILL.md itself) through a symlink into the user's home. Walk each component below the repository root with lstat and refuse the first link, stopping at the first missing component; user-scoped installs stay unchecked because those directories are the user's own and are often symlinked into dotfiles on purpose. Ownership of an existing SKILL.md now requires the exact generated shape (frontmatter, blank line, marker) rather than the marker appearing anywhere, so a hand-written skill that quotes it is not overwritten without --force. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018PqAUQp8FqVcDxcMHXV3ci --- .../hunk/src/core/install/agentSkills.test.ts | 60 +++++++++++++- packages/hunk/src/core/install/agentSkills.ts | 82 ++++++++++++++++++- 2 files changed, 138 insertions(+), 4 deletions(-) diff --git a/packages/hunk/src/core/install/agentSkills.test.ts b/packages/hunk/src/core/install/agentSkills.test.ts index d12df6cea..98cdb4551 100644 --- a/packages/hunk/src/core/install/agentSkills.test.ts +++ b/packages/hunk/src/core/install/agentSkills.test.ts @@ -5,12 +5,14 @@ import { AGENT_SKILL_HOST_IDS, AGENT_SKILL_HOSTS, AGENT_SKILL_STUB_MARKER, + isHunkGeneratedSkill, parseSkillFrontmatter, planAgentSkillTargets, renderAgentSkillStub, resolveAgentSkillHost, runAgentSkillInstallCommand, type AgentSkillInstallInput, + type PathKind, } from "./agentSkills"; const HOME = join("/", "home", "reviewer"); @@ -28,7 +30,11 @@ const REVIEW_SKILL = [ /** Run one install against an in-memory tree, returning output and every file written. */ async function runInstall( input: Partial, - options: { existing?: Record; env?: NodeJS.ProcessEnv } = {}, + options: { + existing?: Record; + env?: NodeJS.ProcessEnv; + pathKinds?: Record; + } = {}, ) { const stdout: string[] = []; const files = new Map(Object.entries(options.existing ?? {})); @@ -44,6 +50,7 @@ async function runInstall( cwd: CWD, readBundledSkill: () => REVIEW_SKILL, readFile: (path) => files.get(path), + inspectPath: (path) => options.pathKinds?.[path] ?? (files.has(path) ? "file" : "missing"), writeFile: (path, text) => { files.set(path, text); written.push(path); @@ -144,6 +151,17 @@ describe("pointer skill rendering", () => { expect(stub).not.toContain("Full instructions that must never be copied"); }); + test("recognizes only the exact generated shape as Hunk's own file", () => { + const stub = renderAgentSkillStub(parseSkillFrontmatter(REVIEW_SKILL)); + expect(isHunkGeneratedSkill(stub)).toBe(true); + expect(isHunkGeneratedSkill(stub.replaceAll("\n", "\r\n"))).toBe(true); + // A hand-written skill that quotes the marker in its body is not Hunk's to overwrite. + expect( + isHunkGeneratedSkill(`---\nname: mine\n---\n\n# Mine\n\nSee ${AGENT_SKILL_STUB_MARKER}`), + ).toBe(false); + expect(isHunkGeneratedSkill(`${AGENT_SKILL_STUB_MARKER}\n# no frontmatter`)).toBe(false); + }); + test("parses the real bundled skills so the pointer never ships blank triggers", () => { for (const name of ["hunk-review", "hunk-extensions"] as const) { const frontmatter = parseSkillFrontmatter(readBundledSkillDocument(name)); @@ -176,7 +194,11 @@ describe("runAgentSkillInstallCommand", () => { const path = join(HOME, ".claude", "skills", "hunk-review", "SKILL.md"); const { exitCode, written } = await runInstall( {}, - { existing: { [path]: `---\nname: hunk-review\n---\n${AGENT_SKILL_STUB_MARKER}\nold body` } }, + { + existing: { + [path]: `---\nname: hunk-review\n---\n\n${AGENT_SKILL_STUB_MARKER}\n\nold body`, + }, + }, ); expect(exitCode).toBe(0); @@ -198,6 +220,40 @@ describe("runAgentSkillInstallCommand", () => { expect(forced.written).toEqual([claudePath, cursorPath]); }); + test("refuses project installs that would follow a symlink inside the checkout", async () => { + const skillsDir = join(CWD, ".claude", "skills"); + const linked = await runInstall( + { scope: "project" }, + { pathKinds: { [join(CWD, ".claude")]: "directory", [skillsDir]: "symlink" } }, + ); + expect(String(linked.error)).toContain(`${skillsDir} is a symlink`); + expect(linked.written).toEqual([]); + + const linkedFile = join(skillsDir, "hunk-review", "SKILL.md"); + const linkedLeaf = await runInstall( + { scope: "project" }, + { + pathKinds: { + [join(CWD, ".claude")]: "directory", + [skillsDir]: "directory", + [join(skillsDir, "hunk-review")]: "directory", + [linkedFile]: "symlink", + }, + }, + ); + expect(String(linkedLeaf.error)).toContain(`${linkedFile} is a symlink`); + expect(linkedLeaf.written).toEqual([]); + + // Real directories all the way down install normally, and a user-scoped install never walks. + const plain = await runInstall( + { scope: "project" }, + { pathKinds: { [join(CWD, ".claude")]: "directory", [skillsDir]: "directory" } }, + ); + expect(plain.written).toEqual([linkedFile]); + const user = await runInstall({}, { pathKinds: { [join(HOME, ".claude")]: "symlink" } }); + expect(user.written).toEqual([join(HOME, ".claude", "skills", "hunk-review", "SKILL.md")]); + }); + test("rejects unknown agents by name", async () => { const { error } = await runInstall({ agents: ["aider" as unknown as AgentSkillInstallInput["agents"][number]], diff --git a/packages/hunk/src/core/install/agentSkills.ts b/packages/hunk/src/core/install/agentSkills.ts index 1d5feb42c..49a62c690 100644 --- a/packages/hunk/src/core/install/agentSkills.ts +++ b/packages/hunk/src/core/install/agentSkills.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import { HunkUserError } from "../run/errors"; import { type BundledSkillName, @@ -25,6 +25,22 @@ import { /** Marker every generated pointer skill carries so reinstalls can tell their own files apart. */ export const AGENT_SKILL_STUB_MARKER = ""; +/** + * Report whether a SKILL.md has the exact shape `renderAgentSkillStub` writes: a frontmatter block, + * one blank line, then the marker. A hand-written skill that merely quotes the marker somewhere in + * its body is not Hunk's to overwrite, so the position matters, not just the presence. + */ +export function isHunkGeneratedSkill(document: string) { + const lines = document.replaceAll("\r\n", "\n").split("\n"); + if (lines[0] !== "---") { + return false; + } + const closing = lines.indexOf("---", 1); + return ( + closing !== -1 && lines[closing + 1] === "" && lines[closing + 2] === AGENT_SKILL_STUB_MARKER + ); +} + /** Inputs a host needs to resolve its user-wide skills directory. */ interface AgentSkillHostContext { env: NodeJS.ProcessEnv; @@ -222,8 +238,13 @@ export interface AgentSkillInstallIo { readBundledSkill?: (name: BundledSkillName) => string; readFile?: (path: string) => string | undefined; writeFile?: (path: string, text: string) => void; + /** What one path is without following links; defaults to `lstat`. */ + inspectPath?: (path: string) => PathKind; } +/** What a path is on disk, as `lstat` reports it, so links are seen rather than followed. */ +export type PathKind = "missing" | "file" | "directory" | "symlink" | "other"; + /** One pointer file the command decided to write, before touching the filesystem. */ export interface AgentSkillInstallTarget { host: AgentSkillHost; @@ -269,6 +290,59 @@ function readFileIfPresent(path: string) { } } +/** Classify one path with `lstat`, so a link is reported as a link rather than as its target. */ +function inspectPathKind(path: string): PathKind { + let stat: fs.Stats; + try { + stat = fs.lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return "missing"; + } + throw error; + } + if (stat.isSymbolicLink()) { + return "symlink"; + } + if (stat.isDirectory()) { + return "directory"; + } + return stat.isFile() ? "file" : "other"; +} + +/** + * Refuse a project-scoped target that a checkout could redirect through a symlink. + * + * Every path component below the repository root is repository content, so a cloned tree can + * make `.claude/skills` (or the SKILL.md itself) a link to somewhere in the user's home and turn + * this write into an overwrite outside the repository. Walking the components with `lstat` and + * stopping at the first missing one catches a link at any depth without following it. User-scoped + * targets are not checked: their directories belong to the user, who may well symlink them into a + * dotfiles checkout on purpose. As with Hunk's workspace write guard, this keeps the tree's own + * layout honest rather than defending against a racing local attacker. + */ +function assertProjectTargetContained( + root: string, + path: string, + inspectPath: (path: string) => PathKind, +) { + const segments = relative(root, path).split(sep); + let current = root; + for (const segment of segments) { + current = join(current, segment); + const kind = inspectPath(current); + if (kind === "missing") { + return; + } + if (kind === "symlink") { + throw new HunkUserError( + `${current} is a symlink; project skill installs refuse to follow links out of the repository.`, + ["Install with the default user scope, or replace the link with a real directory."], + ); + } + } +} + /** Write one file, creating its directories. */ function writeFileWithDirs(path: string, text: string) { fs.mkdirSync(dirname(path), { recursive: true }); @@ -291,6 +365,7 @@ export async function runAgentSkillInstallCommand( const readBundledSkill = io.readBundledSkill ?? readBundledSkillDocument; const readFile = io.readFile ?? readFileIfPresent; const writeFile = io.writeFile ?? writeFileWithDirs; + const inspectPath = io.inspectPath ?? inspectPathKind; const frontmatter = parseSkillFrontmatter(readBundledSkill(input.skill)); const stub = renderAgentSkillStub(frontmatter); @@ -298,8 +373,11 @@ export async function runAgentSkillInstallCommand( // Check every target before writing any, so a refusal leaves no half-installed set behind. for (const target of targets) { + if (input.scope === "project") { + assertProjectTargetContained(cwd, target.path, inspectPath); + } const existing = readFile(target.path); - if (existing !== undefined && !existing.includes(AGENT_SKILL_STUB_MARKER) && !input.force) { + if (existing !== undefined && !isHunkGeneratedSkill(existing) && !input.force) { throw new HunkUserError(`${target.path} already exists and was not generated by Hunk.`, [ "Re-run with --force to replace it, or remove the file first.", ]); From af318b50883314ea3ccc641ebed084b0115478a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 11:59:58 +0000 Subject: [PATCH 4/4] feat(skill): publish pointer skills for `npx skills add modem-dev/hunk` The skills CLI scans a repository's root `skills/` and `.agents/skills/` but not `packages/hunk/skills/`, so `npx skills add modem-dev/hunk` offered only the maintainer-only release and video skills. Generate the same pointer stub `hunk skill install` writes into `.agents/skills//` for each bundled skill, and mark the maintainer skills `metadata.internal` so the CLI hides them. A test keeps the checked-in pointers in sync with the renderer; `bun run generate:skill` regenerates them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018PqAUQp8FqVcDxcMHXV3ci --- .agents/skills/hunk-extensions/SKILL.md | 18 +++++++++ .agents/skills/hunk-review/SKILL.md | 18 +++++++++ .changeset/light-agent-skills.md | 2 +- README.md | 2 +- docs/agent-workflows.md | 2 + packages/hunk/README.md | 2 +- .../hunk/src/core/install/agentSkills.test.ts | 20 +++++++++- scripts/generate/generate-skill.ts | 40 ++++++++++++------- skills/hunk-launch-video/SKILL.md | 2 + skills/hunk-release/SKILL.md | 2 + .../content/docs/docs/agents/review-skill.md | 11 +++++ 11 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 .agents/skills/hunk-extensions/SKILL.md create mode 100644 .agents/skills/hunk-review/SKILL.md diff --git a/.agents/skills/hunk-extensions/SKILL.md b/.agents/skills/hunk-extensions/SKILL.md new file mode 100644 index 000000000..328853f41 --- /dev/null +++ b/.agents/skills/hunk-extensions/SKILL.md @@ -0,0 +1,18 @@ +--- +name: hunk-extensions +description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — hiding or reordering reviewed files, docked 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. +--- + + + +# hunk-extensions + +This file is a pointer. The instructions live in the installed `hunk` CLI so they always match its version. + +Before anything else, load them and follow them: + +```bash +hunk skill show hunk-extensions +``` + +If `hunk` is not on PATH, ask the user to install Hunk first: https://hunk.dev/docs/start/install/ diff --git a/.agents/skills/hunk-review/SKILL.md b/.agents/skills/hunk-review/SKILL.md new file mode 100644 index 000000000..cca52f456 --- /dev/null +++ b/.agents/skills/hunk-review/SKILL.md @@ -0,0 +1,18 @@ +--- +name: hunk-review +description: Interacts with live Hunk diff review sessions via CLI. Inspects review focus, navigates files, hunks, and exact lines, reloads session contents, adds inline review comments, and paints attention marks on character ranges. Use when the user has a Hunk session running or wants to review diffs interactively. +--- + + + +# hunk-review + +This file is a pointer. The instructions live in the installed `hunk` CLI so they always match its version. + +Before anything else, load them and follow them: + +```bash +hunk skill show hunk-review +``` + +If `hunk` is not on PATH, ask the user to install Hunk first: https://hunk.dev/docs/start/install/ diff --git a/.changeset/light-agent-skills.md b/.changeset/light-agent-skills.md index 1ebc4e37a..efa94166d 100644 --- a/.changeset/light-agent-skills.md +++ b/.changeset/light-agent-skills.md @@ -2,4 +2,4 @@ "hunkdiff": minor --- -Add `hunk skill install --agent ` to teach a coding agent how to drive Hunk in one command. It writes a short pointer `SKILL.md` into the skills directory of Claude Code, Codex, opencode, Cursor, Amp, GitHub Copilot, or any tool reading `.agents/skills`; repeat `--agent` for several, and add `--project` to install into the current repository. The pointer keeps only the skill's name and description and loads the rest through the new `hunk skill show [name]`, so it stays current across Hunk upgrades instead of going stale as a copy. +Add `hunk skill install --agent ` to teach a coding agent how to drive Hunk in one command. It writes a short pointer `SKILL.md` into the skills directory of Claude Code, Codex, opencode, Cursor, Amp, GitHub Copilot, or any tool reading `.agents/skills`; repeat `--agent` for several, and add `--project` to install into the current repository. The pointer keeps only the skill's name and description and loads the rest through the new `hunk skill show [name]`, so it stays current across Hunk upgrades instead of going stale as a copy. The repository also publishes the same pointer skills under `.agents/skills/`, so `npx skills add modem-dev/hunk -g` installs them through the skills CLI, and the maintainer-only skills are marked internal so that command no longer offers them. diff --git a/README.md b/README.md index 0a2386f9b..77245b0ad 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Watch mode remains continuous. Direct-file and Git-backed reviews normally use f ### Working with agents 1. Open Hunk in another terminal with `hunk diff` or `hunk show`. -2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). +2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). With Node available, `npx skills add modem-dev/hunk -g` installs the same skill into every agent the [skills CLI](https://github.com/vercel-labs/skills) supports. 3. Ask your agent to review the live Hunk session. The installed skill is a short pointer that loads its instructions from `hunk skill show`, so it stays current across Hunk upgrades. Without an installed skill, a good generic prompt is: diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 00952c8f8..14e8e39cc 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -20,6 +20,8 @@ The installed file is a short pointer that carries the skill's name and descript rest with `hunk skill show`, so it never goes stale when Hunk upgrades. Repeat `--agent` to install into several agents, or add `--project` to write it into the current repository instead of your home directory. Agents that load or symlink files directly can use `hunk skill path` instead. +With Node available, `npx skills add modem-dev/hunk -g` installs the same pointer through the +[skills CLI](https://github.com/vercel-labs/skills); the repository publishes it under `.agents/skills/`. Without an installed skill, a good generic prompt is: diff --git a/packages/hunk/README.md b/packages/hunk/README.md index 0a2386f9b..77245b0ad 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -124,7 +124,7 @@ Watch mode remains continuous. Direct-file and Git-backed reviews normally use f ### Working with agents 1. Open Hunk in another terminal with `hunk diff` or `hunk show`. -2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). +2. Install the review skill into your agent once: `hunk skill install --agent claude` (also `codex`, `opencode`, `cursor`, `amp`, `copilot`, or `agents`). With Node available, `npx skills add modem-dev/hunk -g` installs the same skill into every agent the [skills CLI](https://github.com/vercel-labs/skills) supports. 3. Ask your agent to review the live Hunk session. The installed skill is a short pointer that loads its instructions from `hunk skill show`, so it stays current across Hunk upgrades. Without an installed skill, a good generic prompt is: diff --git a/packages/hunk/src/core/install/agentSkills.test.ts b/packages/hunk/src/core/install/agentSkills.test.ts index 98cdb4551..dcb27cd61 100644 --- a/packages/hunk/src/core/install/agentSkills.test.ts +++ b/packages/hunk/src/core/install/agentSkills.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { readBundledSkillDocument } from "../run/paths"; +import { BUNDLED_SKILL_NAMES, readBundledSkillDocument } from "../run/paths"; import { AGENT_SKILL_HOST_IDS, AGENT_SKILL_HOSTS, @@ -163,12 +164,27 @@ describe("pointer skill rendering", () => { }); test("parses the real bundled skills so the pointer never ships blank triggers", () => { - for (const name of ["hunk-review", "hunk-extensions"] as const) { + for (const name of BUNDLED_SKILL_NAMES) { const frontmatter = parseSkillFrontmatter(readBundledSkillDocument(name)); expect(frontmatter.name).toBe(name); expect(frontmatter.description.length).toBeGreaterThan(20); } }); + + test("checked-in .agents/skills pointers match the generated stub", () => { + // `npx skills add modem-dev/hunk` installs these files, so they must be the same pointer + // `hunk skill install` writes. Regenerate with `bun run generate:skill`. + const repoRoot = join(import.meta.dir, "..", "..", "..", "..", ".."); + for (const name of BUNDLED_SKILL_NAMES) { + const checkedIn = readFileSync(join(repoRoot, ".agents", "skills", name, "SKILL.md"), "utf8"); + const expected = renderAgentSkillStub(parseSkillFrontmatter(readBundledSkillDocument(name))); + if (checkedIn.replaceAll("\r\n", "\n") !== expected) { + throw new Error( + `.agents/skills/${name}/SKILL.md is out of date. Run \`bun run generate:skill\` and commit the result.`, + ); + } + } + }); }); describe("runAgentSkillInstallCommand", () => { diff --git a/scripts/generate/generate-skill.ts b/scripts/generate/generate-skill.ts index 5115c7ee3..4398fc056 100644 --- a/scripts/generate/generate-skill.ts +++ b/scripts/generate/generate-skill.ts @@ -1,19 +1,31 @@ import { join } from "node:path"; +import { + parseSkillFrontmatter, + renderAgentSkillStub, +} from "../../packages/hunk/src/core/install/agentSkills"; import { renderHunkReviewSkill } from "../../packages/hunk/src/hunk-review/skillDocument"; +import { BUNDLED_SKILL_NAMES } from "../../packages/hunk/src/core/run/paths"; /** - * Regenerate `packages/hunk/skills/hunk-review/SKILL.md` from the typed agent surface. The checked-in - * file is the published artifact; the colocated skillDocument test fails when it drifts from the - * renderer, so run this after changing session commands, agent errors, or the skill prose. + * Regenerate the checked-in skill artifacts from source. + * + * `packages/hunk/skills/hunk-review/SKILL.md` is rendered from the typed agent surface; the + * colocated skillDocument test fails when it drifts from the renderer. `.agents/skills//SKILL.md` + * holds the pointer stub for each bundled skill, the same file `hunk skill install` writes, so + * `npx skills add modem-dev/hunk` installs a pointer that defers to the installed CLI instead of a + * copy that goes stale; the agentSkills test fails when those drift. Run this after changing session + * commands, agent errors, skill prose, or either bundled skill's frontmatter. */ -const skillPath = join( - import.meta.dir, - "../..", - "packages", - "hunk", - "skills", - "hunk-review", - "SKILL.md", -); -await Bun.write(skillPath, renderHunkReviewSkill()); -console.log(`Wrote ${skillPath}`); +const repoRoot = join(import.meta.dir, "../.."); +const reviewSkillPath = join(repoRoot, "packages", "hunk", "skills", "hunk-review", "SKILL.md"); +await Bun.write(reviewSkillPath, renderHunkReviewSkill()); +console.log(`Wrote ${reviewSkillPath}`); + +for (const name of BUNDLED_SKILL_NAMES) { + const bundled = await Bun.file( + join(repoRoot, "packages", "hunk", "skills", name, "SKILL.md"), + ).text(); + const pointerPath = join(repoRoot, ".agents", "skills", name, "SKILL.md"); + await Bun.write(pointerPath, renderAgentSkillStub(parseSkillFrontmatter(bundled))); + console.log(`Wrote ${pointerPath}`); +} diff --git a/skills/hunk-launch-video/SKILL.md b/skills/hunk-launch-video/SKILL.md index bfcfb19e5..9dbacd4b7 100644 --- a/skills/hunk-launch-video/SKILL.md +++ b/skills/hunk-launch-video/SKILL.md @@ -1,6 +1,8 @@ --- name: hunk-launch-video description: Produces Hunk videos by driving the real TUI headlessly in a PTY, compositing captioned 1080p frames in Chromium, and encoding with ffmpeg. Use for feature demos, workflow explainers, announcements, launch videos, and full-release roundups. +metadata: + internal: true --- # Hunk video pipeline diff --git a/skills/hunk-release/SKILL.md b/skills/hunk-release/SKILL.md index ae2c29972..30e868e75 100644 --- a/skills/hunk-release/SKILL.md +++ b/skills/hunk-release/SKILL.md @@ -1,6 +1,8 @@ --- name: hunk-release description: Prepares, publishes, verifies, and curates Hunk releases. Use for release metadata, benchmarks, tags, publishing, release videos, backports, or recovery. +metadata: + internal: true --- # Hunk release workflow diff --git a/website/src/content/docs/docs/agents/review-skill.md b/website/src/content/docs/docs/agents/review-skill.md index 819459353..49dc8db2c 100644 --- a/website/src/content/docs/docs/agents/review-skill.md +++ b/website/src/content/docs/docs/agents/review-skill.md @@ -32,6 +32,17 @@ hunk skill show That prints the full skill shipped with the installed Hunk version, so the agent always sees instructions that match the CLI on the machine, and upgrading Hunk never requires reinstalling the skill. +## Or install with the skills CLI + +The repository also publishes the same pointer skills for the [skills CLI](https://github.com/vercel-labs/skills), which knows the skill directories of many more agents: + +```bash +npx skills add modem-dev/hunk -g # every detected agent +npx skills add modem-dev/hunk --skill hunk-review -g # one skill +``` + +It installs the identical pointer, so `hunk skill install` and `npx skills add` can be mixed freely and both defer to the installed CLI for instructions. + ## Locate or read the installed skill ```bash