diff --git a/docs/guide/plugin-packaging.md b/docs/guide/plugin-packaging.md index bfd00f9cb..12ac6729e 100644 --- a/docs/guide/plugin-packaging.md +++ b/docs/guide/plugin-packaging.md @@ -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 (`:`) 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. diff --git a/src/features/hooks/claudecode-hooks.ts b/src/features/hooks/claudecode-hooks.ts index d64969505..0669df3ed 100644 --- a/src/features/hooks/claudecode-hooks.ts +++ b/src/features/hooks/claudecode-hooks.ts @@ -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. @@ -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({ @@ -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( diff --git a/src/features/hooks/claudecode-plugin-hooks.test.ts b/src/features/hooks/claudecode-plugin-hooks.test.ts new file mode 100644 index 000000000..222b43c79 --- /dev/null +++ b/src/features/hooks/claudecode-plugin-hooks.test.ts @@ -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; + + 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"); + }); + }); +}); diff --git a/src/features/hooks/claudecode-plugin-hooks.ts b/src/features/hooks/claudecode-plugin-hooks.ts index 1869133de..8c8bd8ee5 100644 --- a/src/features/hooks/claudecode-plugin-hooks.ts +++ b/src/features/hooks/claudecode-plugin-hooks.ts @@ -3,6 +3,7 @@ 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 { @@ -10,6 +11,19 @@ export class ClaudecodePluginHooks extends ClaudecodeHooks { 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, diff --git a/src/features/subagents/claudecode-plugin-subagent.test.ts b/src/features/subagents/claudecode-plugin-subagent.test.ts new file mode 100644 index 000000000..b59236727 --- /dev/null +++ b/src/features/subagents/claudecode-plugin-subagent.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { createMockLogger } from "../../test-utils/mock-logger.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { ClaudecodePluginSubagent } from "./claudecode-plugin-subagent.js"; +import { ClaudecodeSubagent } from "./claudecode-subagent.js"; +import { RulesyncSubagent, type RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { SubagentsProcessor } from "./subagents-processor.js"; + +const logger = createMockLogger(); + +const buildRulesyncSubagent = ({ + name = "reviewer", + claudecode, +}: { + name?: string; + claudecode?: Record; +}): RulesyncSubagent => { + const frontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"], + name, + description: "A test agent", + ...(claudecode && { claudecode }), + }; + return new RulesyncSubagent({ + outputRoot: ".", + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: `${name}.md`, + frontmatter, + body: "Do the thing.", + validate: false, + }); +}; + +const generate = ({ + outputRoot, + rulesyncSubagent, +}: { + outputRoot: string; + rulesyncSubagent: RulesyncSubagent; +}) => + ClaudecodePluginSubagent.fromRulesyncSubagent({ + outputRoot, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + logger, + }); + +describe("ClaudecodePluginSubagent", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should write agents into the plugin agents directory", () => { + expect(ClaudecodePluginSubagent.getSettablePaths()).toEqual({ relativeDirPath: "agents" }); + }); + }); + + describe("fromRulesyncSubagent", () => { + it("should drop hooks, mcpServers, and permissionMode with a warning", () => { + const subagent = generate({ + outputRoot: testDir, + rulesyncSubagent: buildRulesyncSubagent({ + claudecode: { + model: "haiku", + permissionMode: "acceptEdits", + hooks: { SessionStart: [] }, + mcpServers: {}, + }, + }), + }); + + const { frontmatter } = parseFrontmatter(subagent.getFileContent(), "agents/reviewer.md"); + expect(frontmatter).not.toHaveProperty("permissionMode"); + expect(frontmatter).not.toHaveProperty("hooks"); + expect(frontmatter).not.toHaveProperty("mcpServers"); + // Supported fields are untouched. + expect(frontmatter).toMatchObject({ name: "reviewer", model: "haiku" }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("hooks, mcpServers, permissionMode"), + ); + }); + + it("should keep isolation when it is worktree", () => { + const subagent = generate({ + outputRoot: testDir, + rulesyncSubagent: buildRulesyncSubagent({ claudecode: { isolation: "worktree" } }), + }); + + const { frontmatter } = parseFrontmatter(subagent.getFileContent(), "agents/reviewer.md"); + expect(frontmatter).toMatchObject({ isolation: "worktree" }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("should drop any other isolation value with a warning", () => { + const subagent = generate({ + outputRoot: testDir, + rulesyncSubagent: buildRulesyncSubagent({ claudecode: { isolation: "sandbox" } }), + }); + + const { frontmatter } = parseFrontmatter(subagent.getFileContent(), "agents/reviewer.md"); + expect(frontmatter).not.toHaveProperty("isolation"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('isolation "sandbox"')); + }); + + it("should warn about a name containing the plugin namespace separator", () => { + const subagent = generate({ + outputRoot: testDir, + rulesyncSubagent: buildRulesyncSubagent({ name: "my-plugin:reviewer" }), + }); + + expect(subagent.getFileContent()).toContain("my-plugin:reviewer"); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("reserved for plugin namespacing"), + ); + }); + + it("should warn about only the forbidden field that is actually present", () => { + generate({ + outputRoot: testDir, + rulesyncSubagent: buildRulesyncSubagent({ claudecode: { permissionMode: "plan" } }), + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Dropping permissionMode")); + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining("mcpServers")); + }); + }); + + describe("SubagentsProcessor wiring", () => { + it("should surface the drop warning when generating through the processor", async () => { + const processorLogger = createMockLogger(); + const processor = new SubagentsProcessor({ + logger: processorLogger, + outputRoot: testDir, + toolTarget: "claudecode-plugin", + }); + + await processor.convertRulesyncFilesToToolFiles([ + buildRulesyncSubagent({ claudecode: { permissionMode: "acceptEdits" } }), + ]); + + expect(processorLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Dropping permissionMode"), + ); + }); + }); + + describe("non-plugin claudecode output", () => { + it("should still emit the fields that are forbidden only for plugin agents", () => { + const subagent = ClaudecodeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent: buildRulesyncSubagent({ + claudecode: { permissionMode: "acceptEdits", isolation: "sandbox" }, + }), + logger, + }); + + const { frontmatter } = parseFrontmatter( + subagent.getFileContent(), + ".claude/agents/reviewer.md", + ); + expect(frontmatter).toMatchObject({ permissionMode: "acceptEdits", isolation: "sandbox" }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/subagents/claudecode-plugin-subagent.ts b/src/features/subagents/claudecode-plugin-subagent.ts index f7dacb2c2..923e1ca51 100644 --- a/src/features/subagents/claudecode-plugin-subagent.ts +++ b/src/features/subagents/claudecode-plugin-subagent.ts @@ -1,8 +1,22 @@ import { CLAUDECODE_PLUGIN_AGENTS_DIR } from "../../constants/plugin-paths.js"; -import { ClaudecodeSubagent } from "./claudecode-subagent.js"; +import type { Logger } from "../../utils/logger.js"; +import { ClaudecodeSubagent, type ClaudecodeSubagentFrontmatter } from "./claudecode-subagent.js"; import type { RulesyncSubagent } from "./rulesync-subagent.js"; import type { ToolSubagentSettablePaths } from "./tool-subagent.js"; +/** + * Claude Code refuses these for plugin-shipped agents "for security reasons", + * so emitting them leaves the author believing the agent is constrained when it + * is not. Only these three are dropped: the other fields upstream does not list + * (e.g. `color`) are merely ignored, with no misleading security posture. + * + * @see https://code.claude.com/docs/en/plugins-reference + */ +const PLUGIN_FORBIDDEN_FIELDS = ["hooks", "mcpServers", "permissionMode"] as const; + +/** The only `isolation` value plugin agents accept. */ +const PLUGIN_ISOLATION_VALUE = "worktree"; + export class ClaudecodePluginSubagent extends ClaudecodeSubagent { static override isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { const targets = rulesyncSubagent.getFrontmatter().targets; @@ -12,4 +26,39 @@ export class ClaudecodePluginSubagent extends ClaudecodeSubagent { static override getSettablePaths(): ToolSubagentSettablePaths { return { relativeDirPath: CLAUDECODE_PLUGIN_AGENTS_DIR }; } + + protected static override sanitizeFrontmatter({ + frontmatter, + relativeFilePath, + logger, + }: { + frontmatter: ClaudecodeSubagentFrontmatter; + relativeFilePath: string; + logger?: Logger; + }): ClaudecodeSubagentFrontmatter { + const sanitized: ClaudecodeSubagentFrontmatter = { + ...super.sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }), + }; + + const dropped = PLUGIN_FORBIDDEN_FIELDS.filter((field) => sanitized[field] !== undefined); + for (const field of PLUGIN_FORBIDDEN_FIELDS) { + delete sanitized[field]; + } + if (dropped.length > 0) { + logger?.warn( + `Dropping ${dropped.join(", ")} from claudecode-plugin subagent ${relativeFilePath}: ` + + `Claude Code does not support these fields for plugin-shipped agents.`, + ); + } + + if (sanitized.isolation !== undefined && sanitized.isolation !== PLUGIN_ISOLATION_VALUE) { + logger?.warn( + `Dropping isolation "${sanitized.isolation}" from claudecode-plugin subagent ${relativeFilePath}: ` + + `"${PLUGIN_ISOLATION_VALUE}" is the only value Claude Code accepts for plugin-shipped agents.`, + ); + delete sanitized.isolation; + } + + return sanitized; + } } diff --git a/src/features/subagents/claudecode-subagent.ts b/src/features/subagents/claudecode-subagent.ts index 9d31202f8..64c4f1a77 100644 --- a/src/features/subagents/claudecode-subagent.ts +++ b/src/features/subagents/claudecode-subagent.ts @@ -8,6 +8,7 @@ import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { formatError } from "../../utils/error.js"; import { readFileContent } from "../../utils/file.js"; import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import type { Logger } from "../../utils/logger.js"; import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; import { ToolSubagent, @@ -109,11 +110,40 @@ export class ClaudecodeSubagent extends ToolSubagent { }); } + /** + * Last chance to adjust the tool frontmatter before it is written. The base + * implementation only warns about names Claude Code rejects; plugin-scoped + * subclasses extend it to drop fields Claude Code refuses to honor for + * plugin-shipped agents. + */ + protected static sanitizeFrontmatter({ + frontmatter, + relativeFilePath, + logger, + }: { + frontmatter: ClaudecodeSubagentFrontmatter; + relativeFilePath: string; + logger?: Logger; + }): ClaudecodeSubagentFrontmatter { + // Claude Code 2.1.218 rejects agent markdown files whose name contains `:`, + // which it reserves for plugin namespacing (`:`). The name is + // the author's to fix, so warn rather than failing the whole generate run. + // @see https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md + if (frontmatter.name.includes(":")) { + logger?.warn( + `Claude Code will reject the subagent in ${relativeFilePath}: the name "${frontmatter.name}" ` + + `contains ":", which is reserved for plugin namespacing.`, + ); + } + return frontmatter; + } + static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, + logger, }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); const claudecodeSection = this.filterToolSpecificSection(rulesyncFrontmatter.claudecode ?? {}, [ @@ -136,7 +166,11 @@ export class ClaudecodeSubagent extends ToolSubagent { ); } - const claudecodeFrontmatter = result.data; + const claudecodeFrontmatter = this.sanitizeFrontmatter({ + frontmatter: result.data, + relativeFilePath: rulesyncSubagent.getRelativeFilePath(), + logger, + }); // Generate proper file content with Claude Code specific frontmatter const body = rulesyncSubagent.getBody(); diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 75d234521..69dd3d34f 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -538,6 +538,7 @@ export class SubagentsProcessor extends FeatureProcessor { relativeDirPath: RulesyncSubagent.getSettablePaths().relativeDirPath, rulesyncSubagent: rulesyncSubagent, global: this.global, + logger: this.logger, }), ); } diff --git a/src/features/subagents/tool-subagent.ts b/src/features/subagents/tool-subagent.ts index d765bfd73..9a20e53b0 100644 --- a/src/features/subagents/tool-subagent.ts +++ b/src/features/subagents/tool-subagent.ts @@ -1,6 +1,7 @@ import { AiFileFromFileParams, AiFileParams } from "../../types/ai-file.js"; import { ToolFile } from "../../types/tool-file.js"; import { ToolTarget } from "../../types/tool-targets.js"; +import type { Logger } from "../../utils/logger.js"; import { RulesyncSubagent } from "./rulesync-subagent.js"; export type ToolSubagentFromRulesyncSubagentParams = Omit< @@ -9,6 +10,8 @@ export type ToolSubagentFromRulesyncSubagentParams = Omit< > & { rulesyncSubagent: RulesyncSubagent; global?: boolean; + /** Used to report frontmatter a target drops or rewrites during generation. */ + logger?: Logger; }; export type ToolSubagentSettablePaths = { diff --git a/src/generated/docs-content.ts b/src/generated/docs-content.ts index 725c078a1..94b894a24 100644 --- a/src/generated/docs-content.ts +++ b/src/generated/docs-content.ts @@ -26,7 +26,7 @@ export const DOCS_CONTENT: Record = { "guide/official-skills": "# Official Skills\n\nRulesync provides official skills that you can install using the fetch command or declarative sources:\n\n```bash\n# One-time fetch\nrulesync fetch dyoshikawa/rulesync\n\n# Fetch only specific skills, or pick them interactively\nrulesync fetch dyoshikawa/rulesync --skills rulesync\nrulesync fetch dyoshikawa/rulesync --interactive\n\n# Or declare in rulesync.jsonc and run 'rulesync install'\n```\n\nThis will install the Rulesync documentation skill to your project.\n", "guide/plugin-packaging": - '# Plugin Packaging\n\nRulesync can generate and import configuration components inside existing Claude Code and Google Antigravity plugin directories. Use the packaging targets when the files are distributed as a plugin instead of being installed directly as project or user configuration:\n\n- `claudecode-plugin`\n- `antigravity-plugin`\n\nPackaging targets are project-scope only and are intentionally excluded from `--targets "*"`. Their component directories, such as `skills/` and `rules/`, live directly under the output root and could otherwise collide with ordinary project directories.\n\n## Generate into a plugin\n\nPoint `--output-roots` at the plugin root:\n\n```bash\nrulesync generate \\\n --targets claudecode-plugin \\\n --features mcp,commands,subagents,skills,hooks \\\n --output-roots ./plugins/review-tools\n\nrulesync generate \\\n --targets antigravity-plugin \\\n --features rules,mcp,skills,hooks \\\n --output-roots ./plugins/review-tools\n```\n\nThe same configuration can be persisted in `rulesync.jsonc`:\n\n```jsonc\n{\n "outputRoots": {\n "claudecode-plugin": "./plugins/claude-review-tools",\n "antigravity-plugin": "./plugins/antigravity-review-tools",\n },\n "targets": {\n "claudecode-plugin": ["mcp", "commands", "subagents", "skills", "hooks"],\n "antigravity-plugin": ["rules", "mcp", "skills", "hooks"],\n },\n}\n```\n\nRulesync manages the selected component files but does not create or modify plugin metadata, marketplace catalogs, scripts, or other package assets. Keep the required upstream manifest in the plugin directory:\n\n- Claude Code: `.claude-plugin/plugin.json` when the plugin uses a manifest\n- Antigravity: `plugin.json`\n\nThe plugin root must already exist. Rulesync rejects symbolic links anywhere in the plugin tree before importing, generating, or deleting files so package components cannot escape the selected root.\n\n`--delete` reconciles the selected Rulesync-managed component trees, so do not mix hand-authored files into a component tree that Rulesync owns.\n\n## Import from a plugin\n\nUse `--output-root` to identify the plugin directory to read. Imported canonical files are written to `.rulesync/` in the current working directory:\n\n```bash\nrulesync import \\\n --targets claudecode-plugin \\\n --features mcp,commands,subagents,skills,hooks \\\n --output-root ./plugins/review-tools\n\nrulesync import \\\n --targets antigravity-plugin \\\n --features rules,mcp,skills,hooks \\\n --output-root ./plugins/review-tools\n```\n\nThe `convert` command does not accept packaging targets because it has no separate source and destination plugin roots. Import from the source plugin first, then generate into the destination plugin.\n\n## Component paths\n\n| Target | Rules | MCP | Commands | Subagents | Skills | Hooks |\n| -------------------- | ------------ | ----------------- | --------------- | ------------- | ------------------- | ------------------ |\n| `claudecode-plugin` | — | `.mcp.json` | `commands/*.md` | `agents/*.md` | `skills/*/SKILL.md` | `hooks/hooks.json` |\n| `antigravity-plugin` | `rules/*.md` | `mcp_config.json` | — | — | `skills/*/SKILL.md` | `hooks.json` |\n\nClaude-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.\n\n## Installing a `claudecode-plugin` bundle in JetBrains Junie\n\n[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.\n\nAs with Claude Code, rulesync manages only the component files (`commands/`, `agents/`, `skills/`, `.mcp.json`, `hooks/hooks.json`); the `.claude-plugin/plugin.json` and marketplace catalog remain hand-authored. Junie\'s documentation confirms the manifest-format compatibility but does not enumerate a directory-level mapping for Claude plugin contents, so verify the components you care about after installing.\n', + '# Plugin Packaging\n\nRulesync can generate and import configuration components inside existing Claude Code and Google Antigravity plugin directories. Use the packaging targets when the files are distributed as a plugin instead of being installed directly as project or user configuration:\n\n- `claudecode-plugin`\n- `antigravity-plugin`\n\nPackaging targets are project-scope only and are intentionally excluded from `--targets "*"`. Their component directories, such as `skills/` and `rules/`, live directly under the output root and could otherwise collide with ordinary project directories.\n\n## Generate into a plugin\n\nPoint `--output-roots` at the plugin root:\n\n```bash\nrulesync generate \\\n --targets claudecode-plugin \\\n --features mcp,commands,subagents,skills,hooks \\\n --output-roots ./plugins/review-tools\n\nrulesync generate \\\n --targets antigravity-plugin \\\n --features rules,mcp,skills,hooks \\\n --output-roots ./plugins/review-tools\n```\n\nThe same configuration can be persisted in `rulesync.jsonc`:\n\n```jsonc\n{\n "outputRoots": {\n "claudecode-plugin": "./plugins/claude-review-tools",\n "antigravity-plugin": "./plugins/antigravity-review-tools",\n },\n "targets": {\n "claudecode-plugin": ["mcp", "commands", "subagents", "skills", "hooks"],\n "antigravity-plugin": ["rules", "mcp", "skills", "hooks"],\n },\n}\n```\n\nRulesync manages the selected component files but does not create or modify plugin metadata, marketplace catalogs, scripts, or other package assets. Keep the required upstream manifest in the plugin directory:\n\n- Claude Code: `.claude-plugin/plugin.json` when the plugin uses a manifest\n- Antigravity: `plugin.json`\n\nThe plugin root must already exist. Rulesync rejects symbolic links anywhere in the plugin tree before importing, generating, or deleting files so package components cannot escape the selected root.\n\n`--delete` reconciles the selected Rulesync-managed component trees, so do not mix hand-authored files into a component tree that Rulesync owns.\n\n## Import from a plugin\n\nUse `--output-root` to identify the plugin directory to read. Imported canonical files are written to `.rulesync/` in the current working directory:\n\n```bash\nrulesync import \\\n --targets claudecode-plugin \\\n --features mcp,commands,subagents,skills,hooks \\\n --output-root ./plugins/review-tools\n\nrulesync import \\\n --targets antigravity-plugin \\\n --features rules,mcp,skills,hooks \\\n --output-root ./plugins/review-tools\n```\n\nThe `convert` command does not accept packaging targets because it has no separate source and destination plugin roots. Import from the source plugin first, then generate into the destination plugin.\n\n## Component paths\n\n| Target | Rules | MCP | Commands | Subagents | Skills | Hooks |\n| -------------------- | ------------ | ----------------- | --------------- | ------------- | ------------------- | ------------------ |\n| `claudecode-plugin` | — | `.mcp.json` | `commands/*.md` | `agents/*.md` | `skills/*/SKILL.md` | `hooks/hooks.json` |\n| `antigravity-plugin` | `rules/*.md` | `mcp_config.json` | — | — | `skills/*/SKILL.md` | `hooks.json` |\n\nClaude-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.\n\n## Claude Code plugin constraints\n\nClaude 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:\n\n- **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.\n- **`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.\n\nIndependently of packaging, Rulesync warns when a Claude Code subagent name contains `:`, which Claude Code reserves for plugin namespacing (`:`) and rejects in agent Markdown files.\n\nSee the [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference) for the upstream rules.\n\n## Installing a `claudecode-plugin` bundle in JetBrains Junie\n\n[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.\n\nAs with Claude Code, rulesync manages only the component files (`commands/`, `agents/`, `skills/`, `.mcp.json`, `hooks/hooks.json`); the `.claude-plugin/plugin.json` and marketplace catalog remain hand-authored. Junie\'s documentation confirms the manifest-format compatibility but does not enumerate a directory-level mapping for Claude plugin contents, so verify the components you care about after installing.\n', "guide/separate-input-root": '# Separate Input Root\n\nThe `--input-root ` flag lets you point `rulesync generate` at a `.rulesync/` source directory that is different from the current working directory. This decouples where your rule definitions live from where the generated tool configuration files are written.\n\n> **Currently supported on `generate` only.** At present, `--input-root` is wired into the `rulesync generate` command only. Other commands (`import`, `convert`, `gitignore`, `install`, `fetch`, `init`) still read `.rulesync/` from the current working directory. To use the same source directory with those commands, `cd` into the input-root directory first.\n\n## Primary use case: centralized rules across all repos\n\nA common workflow is to keep a single set of AI rules in a shared directory (e.g. `~/.aiglobal`) and apply them to every project without switching directories:\n\n```bash\n# In any project directory — rules are read from ~/.aiglobal/.rulesync/\nrulesync generate --input-root ~/.aiglobal --targets "*" --features rules\n```\n\nWithout `--input-root`, you would have to `cd ~/.aiglobal && rulesync generate` and then `cd -` back, and the output files would land in `~/.aiglobal` instead of the current project.\n\n## Step-by-step setup\n\n1. Create and initialize a shared rules directory:\n\n ```bash\n mkdir -p ~/.aiglobal\n cd ~/.aiglobal\n rulesync init\n ```\n\n2. Edit your shared rules (`~/.aiglobal/.rulesync/rules/overview.md`, etc.) to your preferences.\n\n3. From any project, generate configurations using the shared rules:\n\n ```bash\n # In your project directory\n rulesync generate --input-root ~/.aiglobal --targets claudecode --features rules\n ```\n\n## Comparison with `--global`\n\nThese two flags serve different but complementary purposes:\n\n| | `--input-root` | `--global` |\n| ------------ | ------------------------------------------------- | ---------------------------------------------------------------------- |\n| **Changes** | Source location (where `.rulesync/` is read from) | Output location (writes to user-scope config paths, e.g. `~/.claude/`) |\n| **Use when** | Your rule definitions live in a non-CWD directory | You want the output to go to the tool\'s global (user-scope) config |\n\nThey can be combined. For example, to read rules from `~/.aiglobal` and write them to Claude Code\'s global settings:\n\n```bash\nrulesync generate --input-root ~/.aiglobal --global --targets claudecode --features rules\n```\n\n> **`--input-root` does not enable `--global`.** When `--input-root` is explicitly provided, Rulesync reads `.rulesync/` from that directory, but output scope still follows the CLI flags: use `--global` for user-scope output, and omit it for project-scope output. A `"global": true` setting in the `rulesync.jsonc` under `--input-root` is **not** applied unless you also pass `--global`, and Rulesync will emit a warning when dropping it so the override is visible.\n\n## Symlinks and trust\n\nRulesync follows symbolic links during file discovery. A symlink inside `.rulesync/` that points outside the directory will be followed transparently, and the resolved file content will be copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks from multiple project directories without duplication.\n\nThe trust boundary is the directory you point Rulesync at. `--input-root` is `resolve()`-ed to an absolute path before use, but there is no `realpath`-based boundary check on individual symlinks inside it. Only run Rulesync against trees you control. Directory symlink cycles are handled safely — discovery results are deduplicated by real path, so a cycle does not produce duplicated output. See the [File Formats § Symlinks](../reference/file-formats.md#symlinks) note for the behavior that applies across all features.\n', "guide/simulated-features":