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
11 changes: 11 additions & 0 deletions docs/guide/plugin-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ The `convert` command does not accept packaging targets because it has no separa

Claude-specific frontmatter and hook overrides continue to use the `claudecode` sections in Rulesync source files. Antigravity plugin output uses the `antigravity-ide` conversion model and override sections because its plugin components follow the Antigravity IDE format.

## Claude Code plugin constraints

Claude Code applies rules to plugin-shipped components that do not apply to the same components installed directly in a project, so `claudecode-plugin` output differs from `claudecode` output in two ways:

- **Hook commands resolve against the plugin, not the consumer's project.** A relative hook command such as `./scripts/fmt.sh` is written as `"$CLAUDE_PLUGIN_ROOT"/scripts/fmt.sh` (the exec form uses the braced `${CLAUDE_PLUGIN_ROOT}/…` placeholder). `$CLAUDE_PROJECT_DIR`, used for the `claudecode` target, would point into each consumer's own repository, where the bundled script does not exist. Import recognizes both forms and converts them back to the relative command. To point at something in the consumer's project instead, write the command with an explicit leading variable, such as `$CLAUDE_PROJECT_DIR/scripts/hook.sh`; commands that already start with a variable are passed through untouched.
- **`hooks`, `mcpServers`, and `permissionMode` are dropped from subagent frontmatter.** Claude Code does not support them for plugin-shipped agents, so Rulesync omits them with a warning rather than writing frontmatter that is silently discarded. `isolation` is likewise dropped unless it is `worktree`, the only value plugin agents accept. Importing from a plugin cannot recover fields that were never written, so keep the canonical `.rulesync/subagents/*.md` files as the source of truth.

Independently of packaging, Rulesync warns when a Claude Code subagent name contains `:`, which Claude Code reserves for plugin namespacing (`<plugin>:<agent>`) and rejects in agent Markdown files.

See the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference) for the upstream rules.

## Installing a `claudecode-plugin` bundle in JetBrains Junie

[Junie CLI Extensions](https://junie.jetbrains.com/docs/junie-cli-extensions.html) — Junie's bundle system for skills, MCP servers, subagents, slash commands, and guidelines — accept two marketplace manifest formats: the native `.junie-extension/marketplace.json` and Claude Code's `.claude-plugin/marketplace.json`. A plugin generated with the `claudecode-plugin` target and published in a Claude-compatible plugin marketplace is therefore installable in Junie via `/extensions`, without a Junie-specific rulesync target.
Expand Down
13 changes: 11 additions & 2 deletions src/features/hooks/claudecode-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export class ClaudecodeHooks extends ToolHooks {
return false;
}

/**
* The converter config used for both directions. Exposed as a static hook so
* plugin-scoped subclasses can swap tool-specific details (e.g. the project
* directory variable) without duplicating the rest of the config.
*/
static getConverterConfig(): ToolHooksConverterConfig {
return CLAUDE_CONVERTER_CONFIG;
}

static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths {
// Currently, both global and project mode use the same paths.
// The parameter is kept for consistency with other ToolHooks implementations.
Expand Down Expand Up @@ -141,7 +150,7 @@ export class ClaudecodeHooks extends ToolHooks {
const claudeHooks = canonicalToToolHooks({
config,
toolOverrideHooks: config.claudecode?.hooks,
converterConfig: CLAUDE_CONVERTER_CONFIG,
converterConfig: this.getConverterConfig(),
logger,
});
const fileContent = applySharedConfigPatch({
Expand Down Expand Up @@ -174,7 +183,7 @@ export class ClaudecodeHooks extends ToolHooks {
}
const hooks = toolHooksToCanonical({
hooks: settings.hooks,
converterConfig: CLAUDE_CONVERTER_CONFIG,
converterConfig: (this.constructor as typeof ClaudecodeHooks).getConverterConfig(),
});
return this.toRulesyncHooksDefault({
fileContent: JSON.stringify(
Expand Down
153 changes: 153 additions & 0 deletions src/features/hooks/claudecode-plugin-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { ClaudecodePluginHooks } from "./claudecode-plugin-hooks.js";
import { RulesyncHooks } from "./rulesync-hooks.js";

const buildRulesyncHooks = ({ testDir, command }: { testDir: string; command: string }) =>
new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: { sessionStart: [{ type: "command", command }] },
}),
validate: false,
});

describe("ClaudecodePluginHooks", () => {
let testDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
({ testDir, cleanup } = await setupTestDirectory());
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});

afterEach(async () => {
await cleanup();
vi.restoreAllMocks();
});

describe("getSettablePaths", () => {
it("should write hooks.json under the plugin hooks directory", () => {
expect(ClaudecodePluginHooks.getSettablePaths()).toEqual({
relativeDirPath: "hooks",
relativeFilePath: "hooks.json",
});
});
});

describe("fromRulesyncHooks", () => {
it("should resolve bundled hook scripts against the plugin root, not the consumer's project", async () => {
const pluginHooks = await ClaudecodePluginHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: buildRulesyncHooks({ testDir, command: "./scripts/fmt.sh" }),
validate: false,
});

const parsed = JSON.parse(pluginHooks.getFileContent());
expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe(
'"$CLAUDE_PLUGIN_ROOT"/scripts/fmt.sh',
);
expect(pluginHooks.getFileContent()).not.toContain("CLAUDE_PROJECT_DIR");
});

it("should use the braced placeholder for the exec form, which has no shell to strip quotes", async () => {
const rulesyncHooks = new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: {
sessionStart: [{ type: "command", command: "./scripts/fmt.sh", args: [] }],
},
}),
validate: false,
});

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

const parsed = JSON.parse(pluginHooks.getFileContent());
expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe(
"${CLAUDE_PLUGIN_ROOT}/scripts/fmt.sh",
);
});

it("should leave a command that already starts with a variable untouched", async () => {
const pluginHooks = await ClaudecodePluginHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: buildRulesyncHooks({
testDir,
command: "$CLAUDE_PROJECT_DIR/scripts/consumer-side.sh",
}),
validate: false,
});

const parsed = JSON.parse(pluginHooks.getFileContent());
expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe(
"$CLAUDE_PROJECT_DIR/scripts/consumer-side.sh",
);
});
});

describe("toRulesyncHooks", () => {
it("should round-trip a plugin-root command back to its relative form", async () => {
const pluginHooks = await ClaudecodePluginHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: buildRulesyncHooks({ testDir, command: "./scripts/fmt.sh" }),
validate: false,
});

const roundTripped = new ClaudecodePluginHooks({
outputRoot: testDir,
relativeDirPath: "hooks",
relativeFilePath: "hooks.json",
fileContent: pluginHooks.getFileContent(),
validate: false,
}).toRulesyncHooks();

const config = JSON.parse(roundTripped.getFileContent());
expect(config.hooks.sessionStart[0].command).toBe("./scripts/fmt.sh");
});

it("should also recognize the braced ${CLAUDE_PLUGIN_ROOT} form used by the exec form", () => {
const fileContent = JSON.stringify({
hooks: {
SessionStart: [
{
hooks: [
{
type: "command",
command: "${CLAUDE_PLUGIN_ROOT}/scripts/fmt.sh",
args: [],
},
],
},
],
},
});

const config = JSON.parse(
new ClaudecodePluginHooks({
outputRoot: testDir,
relativeDirPath: "hooks",
relativeFilePath: "hooks.json",
fileContent,
validate: false,
})
.toRulesyncHooks()
.getFileContent(),
);

expect(config.hooks.sessionStart[0].command).toBe("./scripts/fmt.sh");
});
});
});
14 changes: 14 additions & 0 deletions src/features/hooks/claudecode-plugin-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@ import {
CLAUDECODE_PLUGIN_HOOKS_FILE_NAME,
} from "../../constants/plugin-paths.js";
import { ClaudecodeHooks } from "./claudecode-hooks.js";
import type { ToolHooksConverterConfig } from "./tool-hooks-converter.js";
import type { ToolHooksSettablePaths } from "./tool-hooks.js";

export class ClaudecodePluginHooks extends ClaudecodeHooks {
override isDeletable(): boolean {
return true;
}

/**
* Plugin hook scripts ship inside the plugin, so their commands must resolve
* against the plugin install directory rather than the consumer's project
* root. Upstream documents `"${CLAUDE_PLUGIN_ROOT}"/scripts/format-code.sh`;
* `$CLAUDE_PROJECT_DIR` would expand to a path in the consumer's own repo,
* where the bundled script does not exist.
*
* @see https://code.claude.com/docs/en/plugins-reference
*/
static override getConverterConfig(): ToolHooksConverterConfig {
return { ...super.getConverterConfig(), projectDirVar: "$CLAUDE_PLUGIN_ROOT" };
}

static override getSettablePaths(): ToolHooksSettablePaths {
return {
relativeDirPath: CLAUDECODE_PLUGIN_HOOKS_DIR,
Expand Down
Loading
Loading