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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,13 @@ describe("E2E: hooks (global mode)", () => {
expect(parsed.hooks.sessionStart).toBeDefined();
expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh");
} else if (target === "junie") {
// Junie CLI only supports the `sessionStart` event (PascalCase
// SessionStart), so `stop` (audit.sh) is dropped during generation.
// Junie CLI supports SessionStart, UserPromptSubmit, Stop, and SessionEnd
// (PascalCase), so both `sessionStart` and `stop` (audit.sh) survive.
const parsed = JSON.parse(generatedContent);
expect(parsed.hooks.SessionStart).toBeDefined();
expect(parsed.hooks.Stop).toBeDefined();
expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh");
expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh");
} else if (target === "antigravity-ide" || target === "antigravity-cli") {
// Antigravity nests the event map under a generated `rulesync` hook name
// and supports preToolUse/postToolUse/preModelInvocation/
Expand Down
59 changes: 55 additions & 4 deletions src/features/hooks/junie-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,19 @@ describe("JunieHooks", () => {
});

describe("fromRulesyncHooks", () => {
it("should emit hooks.SessionStart from a canonical sessionStart hook and drop unsupported events", async () => {
it("should emit all supported Junie events from canonical inputs and drop unsupported events", async () => {
await ensureDir(join(testDir, ".junie"));
await writeFileContent(join(testDir, ".junie", "config.json"), JSON.stringify({}));

const config = {
version: 1,
hooks: {
sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }],
stop: [{ command: ".rulesync/hooks/audit.sh" }],
beforeSubmitPrompt: [{ type: "command", command: ".rulesync/hooks/prompt.sh" }],
stop: [{ type: "command", command: ".rulesync/hooks/audit.sh" }],
sessionEnd: [{ type: "command", command: ".rulesync/hooks/session-end.sh" }],
// preToolUse is not a Junie-supported event and must be dropped.
preToolUse: [{ type: "command", command: ".rulesync/hooks/pre-tool.sh" }],
},
};
const rulesyncHooks = new RulesyncHooks({
Expand All @@ -66,8 +70,55 @@ describe("JunieHooks", () => {
expect(JSON.stringify(parsed.hooks.SessionStart)).toContain(
".rulesync/hooks/session-start.sh",
);
// Junie supports only sessionStart, so `stop` is dropped.
expect(parsed.hooks.Stop).toBeUndefined();
// UserPromptSubmit, Stop, and SessionEnd are now supported.
expect(parsed.hooks.UserPromptSubmit).toBeDefined();
expect(JSON.stringify(parsed.hooks.UserPromptSubmit)).toContain(".rulesync/hooks/prompt.sh");
expect(parsed.hooks.Stop).toBeDefined();
expect(JSON.stringify(parsed.hooks.Stop)).toContain(".rulesync/hooks/audit.sh");
expect(parsed.hooks.SessionEnd).toBeDefined();
expect(JSON.stringify(parsed.hooks.SessionEnd)).toContain(".rulesync/hooks/session-end.sh");
// preToolUse is not supported by Junie, so it is dropped.
expect(parsed.hooks.PreToolUse).toBeUndefined();
});

it("should drop matchers on matcher-less events (UserPromptSubmit, Stop) but keep them on SessionStart", async () => {
await ensureDir(join(testDir, ".junie"));
await writeFileContent(join(testDir, ".junie", "config.json"), JSON.stringify({}));

const config = {
version: 1,
hooks: {
sessionStart: [
{ type: "command", command: ".rulesync/hooks/session-start.sh", matcher: "startup" },
],
beforeSubmitPrompt: [
{ type: "command", command: ".rulesync/hooks/prompt.sh", matcher: "ignored" },
],
stop: [{ type: "command", command: ".rulesync/hooks/audit.sh", matcher: "ignored" }],
},
};
const rulesyncHooks = new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify(config),
validate: false,
});

const junieHooks = await JunieHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: false,
});

const parsed = JSON.parse(junieHooks.getFileContent());
// SessionStart supports matchers, so it is preserved.
expect(parsed.hooks.SessionStart[0].matcher).toBe("startup");
// UserPromptSubmit and Stop are matcher-less; the matcher key is stripped.
expect(parsed.hooks.UserPromptSubmit[0].matcher).toBeUndefined();
expect(parsed.hooks.UserPromptSubmit[0].hooks).toBeDefined();
expect(parsed.hooks.Stop[0].matcher).toBeUndefined();
expect(parsed.hooks.Stop[0].hooks).toBeDefined();
});

it("should preserve a pre-existing unrelated key in config.json", async () => {
Expand Down
7 changes: 7 additions & 0 deletions src/features/hooks/junie-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import {
type ToolHooksSettablePaths,
} from "./tool-hooks.js";

// Junie CLI applies matchers only to `SessionStart` / `SessionEnd`;
// `UserPromptSubmit` (beforeSubmitPrompt) and `Stop` (stop) are matcher-less and
// always run, so any matcher on those events is dropped to match the upstream
// capability. See https://junie.jetbrains.com/docs/junie-cli-hooks.html
const JUNIE_NO_MATCHER_EVENTS: ReadonlySet<string> = new Set(["beforeSubmitPrompt", "stop"]);

const JUNIE_CONVERTER_CONFIG: ToolHooksConverterConfig = {
supportedEvents: JUNIE_HOOK_EVENTS,
canonicalToToolEventNames: CANONICAL_TO_JUNIE_EVENT_NAMES,
Expand All @@ -30,6 +36,7 @@ const JUNIE_CONVERTER_CONFIG: ToolHooksConverterConfig = {
// Junie CLI hooks only support `type: "command"`; drop any `prompt`-type
// hooks so generation matches the declared capability.
supportedHookTypes: new Set(["command"]),
noMatcherEvents: JUNIE_NO_MATCHER_EVENTS,
};

export class JunieHooks extends ToolHooks {
Expand Down
29 changes: 29 additions & 0 deletions src/types/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import {
CANONICAL_TO_CURSOR_EVENT_NAMES,
CANONICAL_TO_DEEPAGENTS_EVENT_NAMES,
CANONICAL_TO_FACTORYDROID_EVENT_NAMES,
CANONICAL_TO_JUNIE_EVENT_NAMES,
CANONICAL_TO_OPENCODE_EVENT_NAMES,
CLAUDE_HOOK_EVENTS,
CODEXCLI_HOOK_EVENTS,
CODEXCLI_TO_CANONICAL_EVENT_NAMES,
CURSOR_HOOK_EVENTS,
DEEPAGENTS_HOOK_EVENTS,
FACTORYDROID_HOOK_EVENTS,
JUNIE_HOOK_EVENTS,
JUNIE_TO_CANONICAL_EVENT_NAMES,
OPENCODE_HOOK_EVENTS,
} from "./hooks.js";

Expand Down Expand Up @@ -52,6 +55,32 @@ describe("Event map completeness", () => {
expect(CANONICAL_TO_CODEXCLI_EVENT_NAMES).toHaveProperty(event);
}
});

it("every JUNIE_HOOK_EVENTS entry should exist in CANONICAL_TO_JUNIE_EVENT_NAMES", () => {
for (const event of JUNIE_HOOK_EVENTS) {
expect(CANONICAL_TO_JUNIE_EVENT_NAMES).toHaveProperty(event);
}
});
});

describe("Junie CLI event naming", () => {
it("should map canonical event names to documented Junie PascalCase names", () => {
// Verified against https://junie.jetbrains.com/docs/junie-cli-hooks.html
expect(CANONICAL_TO_JUNIE_EVENT_NAMES.sessionStart).toBe("SessionStart");
expect(CANONICAL_TO_JUNIE_EVENT_NAMES.beforeSubmitPrompt).toBe("UserPromptSubmit");
expect(CANONICAL_TO_JUNIE_EVENT_NAMES.stop).toBe("Stop");
expect(CANONICAL_TO_JUNIE_EVENT_NAMES.sessionEnd).toBe("SessionEnd");
});

it("should support the SessionStart, UserPromptSubmit, Stop, and SessionEnd events", () => {
expect(JUNIE_HOOK_EVENTS).toEqual(["sessionStart", "beforeSubmitPrompt", "stop", "sessionEnd"]);
});

it("should round-trip every Junie event name back to canonical", () => {
for (const [canonical, junie] of Object.entries(CANONICAL_TO_JUNIE_EVENT_NAMES)) {
expect(JUNIE_TO_CANONICAL_EVENT_NAMES[junie]).toBe(canonical);
}
});
});

describe("Codex CLI event naming", () => {
Expand Down
20 changes: 16 additions & 4 deletions src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,21 @@ export const AUGMENTCODE_HOOK_EVENTS: readonly HookEvent[] = [

/**
* Hook events supported by JetBrains Junie CLI.
* Junie CLI currently exposes only the SessionStart lifecycle event
* (matchers `startup` / `resume`), defined under the `"hooks"` key of
* `~/.junie/config.json`. Project-local hooks are ignored for safety.
*
* Junie CLI exposes four lifecycle events under the `"hooks"` key of
* `~/.junie/config.json`: `SessionStart`, `UserPromptSubmit`, `Stop`, and
* `SessionEnd`. Matchers apply only to `SessionStart` / `SessionEnd`
* (e.g. `startup` / `resume`); `UserPromptSubmit` and `Stop` are
* matcher-less. Only `type: "command"` hooks are supported. Project-local
* hooks are ignored for safety.
* @see https://junie.jetbrains.com/docs/junie-cli-hooks.html
*/
export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = ["sessionStart"];
export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = [
"sessionStart",
"beforeSubmitPrompt",
"stop",
"sessionEnd",
];

const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema));

Expand Down Expand Up @@ -675,6 +684,9 @@ export const KIRO_TO_CANONICAL_EVENT_NAMES: Record<string, string> = Object.from
*/
export const CANONICAL_TO_JUNIE_EVENT_NAMES: Record<string, string> = {
sessionStart: "SessionStart",
beforeSubmitPrompt: "UserPromptSubmit",
stop: "Stop",
sessionEnd: "SessionEnd",
};

/**
Expand Down