diff --git a/docs/guide/separate-rulesync-dir.md b/docs/guide/separate-rulesync-dir.md new file mode 100644 index 000000000..fba1bf67f --- /dev/null +++ b/docs/guide/separate-rulesync-dir.md @@ -0,0 +1,52 @@ +# Separate Rulesync Directory + +The `--rulesync-dir ` 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. + +## Primary use case: centralized rules across all repos + +A 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: + +```bash +# In any project directory — rules are read from ~/.aiglobal/.rulesync/ +rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules +``` + +Without `--rulesync-dir`, 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. + +## Step-by-step setup + +1. Create and initialize a shared rules directory: + + ```bash + mkdir -p ~/.aiglobal + cd ~/.aiglobal + rulesync init + ``` + +2. Edit your shared rules (`~/.aiglobal/.rulesync/rules/overview.md`, etc.) to your preferences. + +3. From any project, generate configurations using the shared rules: + + ```bash + # In your project directory + rulesync generate --rulesync-dir ~/.aiglobal --targets claudecode --features rules + ``` + +4. (Optional) Add `--rulesync-dir ~/.aiglobal` to a shell alias or your project's Makefile/taskfile so you do not need to type it every time. + +## Comparison with `--global` + +These two flags serve different but complementary purposes: + +| | `--rulesync-dir` | `--global` | +| ------------ | ------------------------------------------------- | ---------------------------------------------------------------------- | +| **Changes** | Source location (where `.rulesync/` is read from) | Output location (writes to user-scope config paths, e.g. `~/.claude/`) | +| **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 | + +They can be combined. For example, to read rules from `~/.aiglobal` and write them to Claude Code's global settings: + +```bash +rulesync generate --rulesync-dir ~/.aiglobal --global --targets claudecode --features rules +``` + +> **Note:** Passing `--rulesync-dir` does not automatically enable `--global`. When `--rulesync-dir` 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 `--rulesync-dir` is not applied unless you also pass `--global`. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 0f00f93b1..f9283a468 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -33,6 +33,9 @@ rulesync generate --dry-run --targets claudecode --features rules # Check if files are up to date (for CI/CD pipelines) rulesync generate --check --targets "*" --features "*" +# Generate from a shared rules directory (without cd-ing into it) +rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules + # Install skills from declarative sources in rulesync.jsonc rulesync install @@ -64,6 +67,43 @@ rulesync update --check rulesync update --force ``` +## Generate Command + +The `generate` command reads source files from `.rulesync/` and writes AI tool configuration files to the output directories. + +### Options + +| Option | Description | Default | +| --------------------------- | ----------------------------------------------------------------------------------------- | --------------------- | +| `--targets, -t ` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` | +| `--features, -f ` | Comma-separated list of features (rules, commands, subagents, skills, ignore, mcp, hooks) | From `rulesync.jsonc` | +| `--rulesync-dir ` | Path to the directory containing `.rulesync/` source files | CWD | +| `--dry-run` | Show what would change without writing files | `false` | +| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` | +| `--global` | Generate for global (user-scope) configuration files | `false` | +| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` | +| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` | +| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` | + +### Examples + +```bash +# Generate all features for all configured tools +rulesync generate + +# Generate rules for all tools +rulesync generate --targets "*" --features rules + +# Generate from a shared directory without cd-ing into it +rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules + +# Dry run: preview changes without writing +rulesync generate --dry-run --targets claudecode --features rules + +# CI check: fail if generated files are not up to date +rulesync generate --check --targets "*" --features "*" +``` + ## Gitignore Command The `gitignore` command adds generated AI tool configuration files to `.gitignore`. By default, it emits entries only for the tools listed in the `targets` of your `rulesync.jsonc` (controlled by the `gitignoreTargetsOnly` option, which defaults to `true`). Set `gitignoreTargetsOnly` to `false` to emit entries for all supported tools instead. You can also filter the output per-invocation with `--targets` / `--features`, which take precedence over the config. diff --git a/skills/rulesync/cli-commands.md b/skills/rulesync/cli-commands.md index 0f00f93b1..f9283a468 100644 --- a/skills/rulesync/cli-commands.md +++ b/skills/rulesync/cli-commands.md @@ -33,6 +33,9 @@ rulesync generate --dry-run --targets claudecode --features rules # Check if files are up to date (for CI/CD pipelines) rulesync generate --check --targets "*" --features "*" +# Generate from a shared rules directory (without cd-ing into it) +rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules + # Install skills from declarative sources in rulesync.jsonc rulesync install @@ -64,6 +67,43 @@ rulesync update --check rulesync update --force ``` +## Generate Command + +The `generate` command reads source files from `.rulesync/` and writes AI tool configuration files to the output directories. + +### Options + +| Option | Description | Default | +| --------------------------- | ----------------------------------------------------------------------------------------- | --------------------- | +| `--targets, -t ` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` | +| `--features, -f ` | Comma-separated list of features (rules, commands, subagents, skills, ignore, mcp, hooks) | From `rulesync.jsonc` | +| `--rulesync-dir ` | Path to the directory containing `.rulesync/` source files | CWD | +| `--dry-run` | Show what would change without writing files | `false` | +| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` | +| `--global` | Generate for global (user-scope) configuration files | `false` | +| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` | +| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` | +| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` | + +### Examples + +```bash +# Generate all features for all configured tools +rulesync generate + +# Generate rules for all tools +rulesync generate --targets "*" --features rules + +# Generate from a shared directory without cd-ing into it +rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules + +# Dry run: preview changes without writing +rulesync generate --dry-run --targets claudecode --features rules + +# CI check: fail if generated files are not up to date +rulesync generate --check --targets "*" --features "*" +``` + ## Gitignore Command The `gitignore` command adds generated AI tool configuration files to `.gitignore`. By default, it emits entries only for the tools listed in the `targets` of your `rulesync.jsonc` (controlled by the `gitignoreTargetsOnly` option, which defaults to `true`). Set `gitignoreTargetsOnly` to `false` to emit entries for all supported tools instead. You can also filter the output per-invocation with `--targets` / `--features`, which take precedence over the config. diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index 5b83070bb..0cf176094 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -56,6 +56,7 @@ describe("generateCommand", () => { getDryRun: vi.fn().mockReturnValue(false), getCheck: vi.fn().mockReturnValue(false), isPreviewMode: vi.fn().mockReturnValue(false), + getRulesyncDir: vi.fn().mockReturnValue(process.cwd()), }; vi.mocked(ConfigResolver.resolve).mockResolvedValue(mockConfig); @@ -1058,6 +1059,133 @@ describe("generateCommand", () => { }); }); + describe("rulesyncDir decoupling", () => { + // Rules source dir (where .rulesync/ lives) is independent of output baseDirs. + const rulesyncDir = "/central/rulesync-source"; + const baseDirs = ["/project/app-one", "/project/app-two"]; + + beforeEach(() => { + mockConfig.getRulesyncDir.mockReturnValue(rulesyncDir); + mockConfig.getBaseDirs.mockReturnValue(baseDirs); + }); + + it("should check for .rulesync under rulesyncDir, not under baseDirs", async () => { + mockConfig.getFeatures.mockReturnValue(["rules"]); + + await generateCommand(mockLogger, {}); + + expect(fileExists).toHaveBeenCalledWith("/central/rulesync-source/.rulesync"); + expect(fileExists).not.toHaveBeenCalledWith("/project/app-one/.rulesync"); + expect(fileExists).not.toHaveBeenCalledWith("/project/app-two/.rulesync"); + }); + + it("should construct RulesProcessor with rulesyncDir distinct from baseDir for each output dir", async () => { + mockConfig.getFeatures.mockReturnValue(["rules"]); + + await generateCommand(mockLogger, {}); + + expect(RulesProcessor).toHaveBeenCalledTimes(2); + expect(RulesProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-one", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + expect(RulesProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-two", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + }); + + it("should pass rulesyncDir to IgnoreProcessor independently of baseDir", async () => { + mockConfig.getFeatures.mockReturnValue(["ignore"]); + + await generateCommand(mockLogger, {}); + + expect(IgnoreProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-one", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + expect(IgnoreProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-two", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + }); + + it("should pass rulesyncDir to McpProcessor independently of baseDir", async () => { + mockConfig.getFeatures.mockReturnValue(["mcp"]); + + await generateCommand(mockLogger, {}); + + expect(McpProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-one", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + expect(McpProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-two", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + }); + + it("should pass rulesyncDir to CommandsProcessor independently of baseDir", async () => { + mockConfig.getFeatures.mockReturnValue(["commands"]); + + await generateCommand(mockLogger, {}); + + expect(CommandsProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-one", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + expect(CommandsProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-two", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + }); + + it("should pass rulesyncDir to SubagentsProcessor independently of baseDir", async () => { + mockConfig.getFeatures.mockReturnValue(["subagents"]); + + await generateCommand(mockLogger, {}); + + expect(SubagentsProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-one", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + expect(SubagentsProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir: "/project/app-two", + rulesyncDir, + toolTarget: "claudecode", + }), + ); + }); + }); + describe("integration scenarios", () => { it("should handle mixed success and failure scenarios", async () => { mockConfig.getFeatures.mockReturnValue(["rules", "ignore"]); diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index faff40cfa..b02d5462a 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -42,7 +42,7 @@ export async function generateCommand(logger: Logger, options: GenerateOptions): logger.debug("Generating files..."); - if (!(await checkRulesyncDirExists({ baseDir: process.cwd() }))) { + if (!(await checkRulesyncDirExists({ baseDir: config.getRulesyncDir() }))) { throw new CLIError( ".rulesync directory not found. Run 'rulesync init' first.", ErrorCodes.RULESYNC_DIR_NOT_FOUND, diff --git a/src/cli/commands/import.test.ts b/src/cli/commands/import.test.ts index 60871f12b..450a41875 100644 --- a/src/cli/commands/import.test.ts +++ b/src/cli/commands/import.test.ts @@ -32,6 +32,7 @@ describe("importCommand", () => { getFeatureOptions: vi.fn().mockReturnValue(undefined), getGlobal: vi.fn().mockReturnValue(false), getBaseDirs: vi.fn().mockReturnValue(["."]), + getRulesyncDir: vi.fn().mockReturnValue(process.cwd()), }; vi.mocked(ConfigResolver.resolve).mockResolvedValue(mockConfig); diff --git a/src/cli/index.ts b/src/cli/index.ts index af753b92a..0ee9accf0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -195,7 +195,7 @@ const main = async () => { ) .option("--delete", "Delete all existing files in output directories before generating") .option( - "-b, --base-dir ", + "-b, --base-dirs ", "Base directories to generate files (comma-separated for multiple paths)", parseCommaSeparatedList, ) @@ -215,6 +215,10 @@ const main = async () => { "--simulate-skills", "Generate simulated skills. This feature is only available for copilot, cursor and codexcli.", ) + .option( + "--rulesync-dir ", + "Path to the directory containing .rulesync/ (parent of .rulesync/)", + ) .option("--dry-run", "Dry run: show changes without writing files") .option("--check", "Check if files are up to date (exits with code 1 if changes needed)") .action( diff --git a/src/config/config-resolver.test.ts b/src/config/config-resolver.test.ts index 3bf50a41d..713de9101 100644 --- a/src/config/config-resolver.test.ts +++ b/src/config/config-resolver.test.ts @@ -396,6 +396,158 @@ describe("config-resolver", () => { }); }); + describe("rulesyncDir — configPath resolution", () => { + it("should resolve a relative configPath against rulesyncDir, not cwd", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["claudecode"], verbose: true }), + ); + // A differently-configured file in cwd that must NOT be picked up. + await writeFileContent( + join(testDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["cursor"], verbose: false }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + }); + + expect(config.getTargets()).toEqual(["claudecode"]); + expect(config.getVerbose()).toBe(true); + }); + + it("should resolve the default configPath against rulesyncDir when no configPath is provided", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["claudecode"] }), + ); + await writeFileContent( + join(testDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["cursor"] }), + ); + + const config = await ConfigResolver.resolve({ rulesyncDir }); + + expect(config.getTargets()).toEqual(["claudecode"]); + }); + + it("should load rulesync.local.jsonc from rulesyncDir alongside the base config", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["cursor"], verbose: false }), + ); + await writeFileContent( + join(rulesyncDir, "rulesync.local.jsonc"), + JSON.stringify({ targets: ["claudecode"], verbose: true }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + }); + + expect(config.getTargets()).toEqual(["claudecode"]); + expect(config.getVerbose()).toBe(true); + }); + + it("should reject a relative configPath that escapes rulesyncDir", async () => { + const rulesyncDir = join(testDir, "central-rules"); + // Ensure parent contains a tempting target. + await writeFileContent( + join(testDir, "rulesync.jsonc"), + JSON.stringify({ targets: ["cursor"] }), + ); + + await expect( + ConfigResolver.resolve({ + configPath: "../rulesync.jsonc", + rulesyncDir, + }), + ).rejects.toThrow("Path traversal detected"); + }); + }); + + describe("rulesyncDir — global precedence", () => { + it("should force global to false when rulesyncDir is set and config file has global: true", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ baseDirs: ["./"], global: true }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + }); + + expect(config.getGlobal()).toBe(false); + }); + + it("should honor config file global: true when rulesyncDir is omitted", async () => { + await writeFileContent( + join(testDir, "rulesync.jsonc"), + JSON.stringify({ baseDirs: ["./"], global: true }), + ); + + const config = await ConfigResolver.resolve({ + configPath: join(testDir, "rulesync.jsonc"), + }); + + expect(config.getGlobal()).toBe(true); + }); + + it("should allow CLI --global true to re-enable global even when rulesyncDir is set", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ baseDirs: ["./"], global: true }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + global: true, + }); + + expect(config.getGlobal()).toBe(true); + }); + + it("should let explicit CLI --global false win over config file global: true with rulesyncDir set", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ baseDirs: ["./"], global: true }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + global: false, + }); + + expect(config.getGlobal()).toBe(false); + }); + + it("should keep global false by default when rulesyncDir is set and config file does not set global", async () => { + const rulesyncDir = join(testDir, "central-rules"); + await writeFileContent( + join(rulesyncDir, "rulesync.jsonc"), + JSON.stringify({ baseDirs: ["./"] }), + ); + + const config = await ConfigResolver.resolve({ + configPath: "rulesync.jsonc", + rulesyncDir, + }); + + expect(config.getGlobal()).toBe(false); + }); + }); + describe("deprecation warning for object-form features", () => { let warnSpy: ReturnType; diff --git a/src/config/config-resolver.ts b/src/config/config-resolver.ts index 874b3aee9..9f9c226e1 100644 --- a/src/config/config-resolver.ts +++ b/src/config/config-resolver.ts @@ -20,7 +20,6 @@ import { ConfigFileSchema, ConfigParams, PartialConfigParams, - RequiredConfigParams, } from "./config.js"; import { emitFeaturesObjectFormDeprecationWarning } from "./deprecation-warnings.js"; @@ -33,7 +32,7 @@ export type ConfigResolverResolveParams = Partial< } >; -const getDefaults = (): RequiredConfigParams & { configPath: string } => ({ +const getDefaults = (): ConfigParams & { configPath: string } => ({ targets: ["agentsmd"], features: ["rules"], verbose: false, @@ -49,6 +48,7 @@ const getDefaults = (): RequiredConfigParams & { configPath: string } => ({ gitignoreDestination: "gitignore", dryRun: false, check: false, + rulesyncDir: undefined, sources: [], }); @@ -121,9 +121,13 @@ export class ConfigResolver { dryRun, check, gitignoreDestination, + rulesyncDir, }: ConfigResolverResolveParams): Promise { // Validate configPath to prevent path traversal attacks - const validatedConfigPath = resolvePath(configPath, process.cwd()); + // When rulesyncDir is set, resolve the config path relative to it so that + // the user's central .rulesync source dir is also the config source. + const configBaseDir = rulesyncDir ?? process.cwd(); + const validatedConfigPath = resolvePath(configPath, configBaseDir); // Load base config (rulesync.jsonc) const baseConfig = await loadConfigFromFile(validatedConfigPath); @@ -156,7 +160,11 @@ export class ConfigResolver { ); } - const resolvedGlobal = global ?? configByFile.global ?? getDefaults().global; + // When --rulesync-dir is explicitly provided the user is decoupling source + // from output, so "global: true" from the config file must not apply unless + // the caller also explicitly passes --global. + const configGlobal = rulesyncDir !== undefined ? false : configByFile.global; + const resolvedGlobal = global ?? configGlobal ?? getDefaults().global ?? false; const resolvedSimulateCommands = simulateCommands ?? configByFile.simulateCommands ?? getDefaults().simulateCommands; const resolvedSimulateSubagents = @@ -215,6 +223,7 @@ export class ConfigResolver { getDefaults().gitignoreDestination, dryRun: dryRun ?? configByFile.dryRun ?? getDefaults().dryRun, check: check ?? configByFile.check ?? getDefaults().check, + rulesyncDir: rulesyncDir ?? configByFile.rulesyncDir ?? getDefaults().rulesyncDir, sources: configByFile.sources ?? getDefaults().sources, }; return new Config(configParams); diff --git a/src/config/config.ts b/src/config/config.ts index 8fdc61c80..3d8d734ea 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -70,6 +70,7 @@ export const ConfigParamsSchema = z.object({ gitignoreDestination: optional(GitignoreDestinationSchema), dryRun: optional(z.boolean()), check: optional(z.boolean()), + rulesyncDir: optional(z.string()), // Declarative skill sources sources: optional(z.array(SourceEntrySchema)), }); @@ -219,6 +220,7 @@ export class Config { private readonly gitignoreDestination: GitignoreDestination; private readonly dryRun: boolean; private readonly check: boolean; + private readonly rulesyncDir: string | undefined; private readonly sources: SourceEntry[]; constructor({ @@ -236,6 +238,7 @@ export class Config { gitignoreDestination, dryRun, check, + rulesyncDir, sources, }: ConfigParams) { // Defense-in-depth: enforce the same mutual-exclusivity rule that the @@ -288,6 +291,7 @@ export class Config { this.gitignoreDestination = gitignoreDestination ?? "gitignore"; this.dryRun = dryRun ?? false; this.check = check ?? false; + this.rulesyncDir = rulesyncDir; this.sources = sources ?? []; } @@ -579,6 +583,10 @@ export class Config { return this.check; } + public getRulesyncDir(): string { + return this.rulesyncDir ?? process.cwd(); + } + public getSources(): SourceEntry[] { return this.sources; } diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 152531d75..655615d30 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -746,11 +746,17 @@ describe("CommandsProcessor", () => { const result = await processor.loadRulesyncFiles(); expect(mockFindFilesByGlobs).toHaveBeenCalledWith( - join(RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"), + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"), ); expect(RulesyncCommand.fromFile).toHaveBeenCalledTimes(2); - expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ relativeFilePath: "test1.md" }); - expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ relativeFilePath: "test2.md" }); + expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ + baseDir: testDir, + relativeFilePath: "test1.md", + }); + expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ + baseDir: testDir, + relativeFilePath: "test2.md", + }); expect(logger.debug).toHaveBeenCalledWith("Successfully loaded 2 rulesync commands"); expect(result).toEqual(mockRulesyncCommands); }); @@ -826,10 +832,14 @@ describe("CommandsProcessor", () => { const result = await processor.loadRulesyncFiles(); - expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ + expect(RulesyncCommand.fromFile).toHaveBeenNthCalledWith(1, { + baseDir: testDir, relativeFilePath: join("pj", "foo.md"), }); - expect(RulesyncCommand.fromFile).toHaveBeenCalledWith({ relativeFilePath: "bar.md" }); + expect(RulesyncCommand.fromFile).toHaveBeenNthCalledWith(2, { + baseDir: testDir, + relativeFilePath: "bar.md", + }); expect(result).toEqual(mockRulesyncCommands); }); diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 535167849..20da234c0 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -328,6 +328,7 @@ export class CommandsProcessor extends FeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory, @@ -335,13 +336,14 @@ export class CommandsProcessor extends FeatureProcessor { logger, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; global?: boolean; getFactory?: GetFactory; dryRun?: boolean; logger: Logger; }) { - super({ baseDir, dryRun, logger }); + super({ baseDir, rulesyncDir, dryRun, logger }); const result = CommandsProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -421,12 +423,15 @@ export class CommandsProcessor extends FeatureProcessor { * Load and parse rulesync command files from .rulesync/commands/ directory */ async loadRulesyncFiles(): Promise { - const basePath = RulesyncCommand.getSettablePaths().relativeDirPath; + const basePath = join(this.rulesyncDir, RulesyncCommand.getSettablePaths().relativeDirPath); const rulesyncCommandPaths = await findFilesByGlobs(join(basePath, "**", "*.md")); const rulesyncCommands = await Promise.all( rulesyncCommandPaths.map((path) => - RulesyncCommand.fromFile({ relativeFilePath: this.safeRelativePath(basePath, path) }), + RulesyncCommand.fromFile({ + baseDir: this.rulesyncDir, + relativeFilePath: this.safeRelativePath(basePath, path), + }), ), ); diff --git a/src/features/commands/rulesync-command.ts b/src/features/commands/rulesync-command.ts index 53a35ca95..efc6465e3 100644 --- a/src/features/commands/rulesync-command.ts +++ b/src/features/commands/rulesync-command.ts @@ -110,11 +110,12 @@ export class RulesyncCommand extends RulesyncFile { } static async fromFile({ + baseDir = process.cwd(), relativeFilePath, }: RulesyncFileFromFileParams): Promise { // Read file content const filePath = join( - process.cwd(), + baseDir, RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath, ); @@ -128,7 +129,7 @@ export class RulesyncCommand extends RulesyncFile { } return new RulesyncCommand({ - baseDir: process.cwd(), + baseDir, relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath, frontmatter: result.data, diff --git a/src/features/ignore/ignore-processor.ts b/src/features/ignore/ignore-processor.ts index 7a9fc724a..5d1a61754 100644 --- a/src/features/ignore/ignore-processor.ts +++ b/src/features/ignore/ignore-processor.ts @@ -97,6 +97,7 @@ export class IgnoreProcessor extends FeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, getFactory = defaultGetFactory, dryRun = false, @@ -104,13 +105,14 @@ export class IgnoreProcessor extends FeatureProcessor { featureOptions, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; getFactory?: GetFactory; dryRun?: boolean; logger: Logger; featureOptions?: FeatureOptions; }) { - super({ baseDir, dryRun, logger }); + super({ baseDir, rulesyncDir, dryRun, logger }); const result = IgnoreProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -133,7 +135,7 @@ export class IgnoreProcessor extends FeatureProcessor { */ async loadRulesyncFiles(): Promise { try { - return [await RulesyncIgnore.fromFile()]; + return [await RulesyncIgnore.fromFile({ baseDir: this.rulesyncDir })]; } catch (error) { this.logger.error( `Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`, diff --git a/src/features/ignore/rulesync-ignore.ts b/src/features/ignore/rulesync-ignore.ts index 4cf1399a2..090d7ec32 100644 --- a/src/features/ignore/rulesync-ignore.ts +++ b/src/features/ignore/rulesync-ignore.ts @@ -38,8 +38,9 @@ export class RulesyncIgnore extends RulesyncFile { }; } - static async fromFile(): Promise { - const baseDir = process.cwd(); + static async fromFile({ + baseDir = process.cwd(), + }: { baseDir?: string } = {}): Promise { const paths = this.getSettablePaths(); const recommendedPath = join( baseDir, diff --git a/src/features/mcp/mcp-processor.test.ts b/src/features/mcp/mcp-processor.test.ts index 825f611e1..8bea935b8 100644 --- a/src/features/mcp/mcp-processor.test.ts +++ b/src/features/mcp/mcp-processor.test.ts @@ -177,7 +177,7 @@ describe("McpProcessor", () => { expect(files).toHaveLength(1); expect(files[0]).toBe(mockRulesyncMcp); - expect(RulesyncMcp.fromFile).toHaveBeenCalledWith({}); + expect(RulesyncMcp.fromFile).toHaveBeenCalledWith({ baseDir: testDir }); }); it("should return empty array when no MCP files found", async () => { diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index dc29e6cc8..a11a6313f 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -317,6 +317,7 @@ export class McpProcessor extends FeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory, @@ -324,13 +325,14 @@ export class McpProcessor extends FeatureProcessor { logger, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; global?: boolean; getFactory?: GetFactory; dryRun?: boolean; logger: Logger; }) { - super({ baseDir, dryRun, logger }); + super({ baseDir, rulesyncDir, dryRun, logger }); const result = McpProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -348,7 +350,7 @@ export class McpProcessor extends FeatureProcessor { */ async loadRulesyncFiles(): Promise { try { - return [await RulesyncMcp.fromFile({})]; + return [await RulesyncMcp.fromFile({ baseDir: this.rulesyncDir })]; } catch (error) { this.logger.error( `Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`, diff --git a/src/features/mcp/rulesync-mcp.ts b/src/features/mcp/rulesync-mcp.ts index c27dd9987..b7f6a9a24 100644 --- a/src/features/mcp/rulesync-mcp.ts +++ b/src/features/mcp/rulesync-mcp.ts @@ -35,7 +35,7 @@ export const RulesyncMcpFileSchema = z.looseObject({ export type RulesyncMcpParams = RulesyncFileParams; -export type RulesyncMcpFromFileParams = Pick; +export type RulesyncMcpFromFileParams = Pick; export type RulesyncMcpSettablePaths = { recommended: { @@ -86,10 +86,10 @@ export class RulesyncMcp extends RulesyncFile { } static async fromFile({ + baseDir = process.cwd(), validate = true, logger, }: RulesyncMcpFromFileParams & { logger?: Logger }): Promise { - const baseDir = process.cwd(); const paths = this.getSettablePaths(); const recommendedPath = join( baseDir, diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 62fa86f0f..e000690b4 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -565,6 +565,7 @@ export class RulesProcessor extends FeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, simulateCommands = false, simulateSubagents = false, @@ -577,6 +578,7 @@ export class RulesProcessor extends FeatureProcessor { logger, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; global?: boolean; simulateCommands?: boolean; @@ -588,7 +590,7 @@ export class RulesProcessor extends FeatureProcessor { dryRun?: boolean; logger: Logger; }) { - super({ baseDir, dryRun, logger }); + super({ baseDir, rulesyncDir, dryRun, logger }); const result = RulesProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -887,7 +889,7 @@ export class RulesProcessor extends FeatureProcessor { * Load and parse rulesync rule files from .rulesync/rules/ directory */ async loadRulesyncFiles(): Promise { - const rulesyncBaseDir = join(process.cwd(), RULESYNC_RULES_RELATIVE_DIR_PATH); + const rulesyncBaseDir = join(this.rulesyncDir, RULESYNC_RULES_RELATIVE_DIR_PATH); const files = await findFilesByGlobs(join(rulesyncBaseDir, "**", "*.md")); this.logger.debug(`Found ${files.length} rulesync files`); const rulesyncRules = await Promise.all( @@ -898,6 +900,7 @@ export class RulesProcessor extends FeatureProcessor { intendedRootDir: rulesyncBaseDir, }); return RulesyncRule.fromFile({ + baseDir: this.rulesyncDir, relativeFilePath, }); }), diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index f6ec2890e..1d07cf1a0 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -137,11 +137,12 @@ export class RulesyncRule extends RulesyncFile { } static async fromFile({ + baseDir = process.cwd(), relativeFilePath, validate = true, }: RulesyncFileFromFileParams): Promise { const filePath = join( - process.cwd(), + baseDir, this.getSettablePaths().recommended.relativeDirPath, relativeFilePath, ); @@ -164,7 +165,7 @@ export class RulesyncRule extends RulesyncFile { }; return new RulesyncRule({ - baseDir: process.cwd(), + baseDir, relativeDirPath: this.getSettablePaths().recommended.relativeDirPath, relativeFilePath, frontmatter: validatedFrontmatter, diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 982e0ba41..8cc663ccd 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -273,6 +273,7 @@ export class SkillsProcessor extends DirFeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory, @@ -280,13 +281,14 @@ export class SkillsProcessor extends DirFeatureProcessor { logger, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; global?: boolean; getFactory?: GetFactory; dryRun?: boolean; logger: Logger; }) { - super({ baseDir, dryRun, avoidBlockScalars: toolTarget === "cursor", logger }); + super({ baseDir, rulesyncDir, dryRun, avoidBlockScalars: toolTarget === "cursor", logger }); const result = SkillsProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -345,18 +347,18 @@ export class SkillsProcessor extends DirFeatureProcessor { */ async loadRulesyncDirs(): Promise { // Load local skills (directly under .rulesync/skills/) - const localDirNames = [...(await getLocalSkillDirNames(process.cwd()))]; + const localDirNames = [...(await getLocalSkillDirNames(this.rulesyncDir))]; const localSkills = await Promise.all( localDirNames.map((dirName) => - RulesyncSkill.fromDir({ baseDir: process.cwd(), dirName, global: this.global }), + RulesyncSkill.fromDir({ baseDir: this.rulesyncDir, dirName, global: this.global }), ), ); const localSkillNames = new Set(localDirNames); // Load curated (remote) skills from .curated/ subdirectory - const curatedDirPath = join(process.cwd(), RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH); + const curatedDirPath = join(this.rulesyncDir, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH); let curatedSkills: RulesyncSkill[] = []; if (await directoryExists(curatedDirPath)) { @@ -376,7 +378,7 @@ export class SkillsProcessor extends DirFeatureProcessor { curatedSkills = await Promise.all( nonConflicting.map((dirName) => RulesyncSkill.fromDir({ - baseDir: process.cwd(), + baseDir: this.rulesyncDir, relativeDirPath: curatedRelativeDirPath, dirName, global: this.global, diff --git a/src/features/subagents/rulesync-subagent.ts b/src/features/subagents/rulesync-subagent.ts index d0ed08141..8820f199e 100644 --- a/src/features/subagents/rulesync-subagent.ts +++ b/src/features/subagents/rulesync-subagent.ts @@ -102,10 +102,11 @@ export class RulesyncSubagent extends RulesyncFile { } static async fromFile({ + baseDir = process.cwd(), relativeFilePath, }: RulesyncSubagentFromFileParams): Promise { // Read file content - const filePath = join(process.cwd(), RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath); + const filePath = join(baseDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath); const fileContent = await readFileContent(filePath); const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); @@ -118,7 +119,7 @@ export class RulesyncSubagent extends RulesyncFile { const filename = basename(relativeFilePath); return new RulesyncSubagent({ - baseDir: process.cwd(), + baseDir, relativeDirPath: this.getSettablePaths().relativeDirPath, relativeFilePath: filename, frontmatter: result.data, diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 9675b99d2..b43dace1b 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -234,6 +234,7 @@ export class SubagentsProcessor extends FeatureProcessor { constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory, @@ -241,13 +242,14 @@ export class SubagentsProcessor extends FeatureProcessor { logger, }: { baseDir?: string; + rulesyncDir?: string; toolTarget: ToolTarget; global?: boolean; getFactory?: GetFactory; dryRun?: boolean; logger: Logger; }) { - super({ baseDir, dryRun, logger }); + super({ baseDir, rulesyncDir, dryRun, logger }); const result = SubagentsProcessorToolTargetSchema.safeParse(toolTarget); if (!result.success) { throw new Error( @@ -310,7 +312,10 @@ export class SubagentsProcessor extends FeatureProcessor { * Load and parse rulesync subagent files from .rulesync/subagents/ directory */ async loadRulesyncFiles(): Promise { - const subagentsDir = join(process.cwd(), RulesyncSubagent.getSettablePaths().relativeDirPath); + const subagentsDir = join( + this.rulesyncDir, + RulesyncSubagent.getSettablePaths().relativeDirPath, + ); // Check if directory exists const dirExists = await directoryExists(subagentsDir); @@ -338,6 +343,7 @@ export class SubagentsProcessor extends FeatureProcessor { try { const rulesyncSubagent = await RulesyncSubagent.fromFile({ + baseDir: this.rulesyncDir, relativeFilePath: mdFile, validate: true, }); diff --git a/src/index.test.ts b/src/index.test.ts index 2bd8e7e38..98c00faf1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -32,6 +32,7 @@ vi.mock("./utils/logger.js", async (importOriginal) => { const mockConfig = { getBaseDirs: () => ["/project"], + getRulesyncDir: () => process.cwd(), } as unknown as Config; const mockGenerateResult: GenerateResult = { diff --git a/src/lib/generate.test.ts b/src/lib/generate.test.ts index b5b36b1f5..0d3a9c08e 100644 --- a/src/lib/generate.test.ts +++ b/src/lib/generate.test.ts @@ -81,6 +81,7 @@ describe("generate", () => { getSimulateSubagents: ReturnType; getSimulateSkills: ReturnType; isPreviewMode: ReturnType; + getRulesyncDir: ReturnType; }; beforeEach(() => { @@ -98,6 +99,7 @@ describe("generate", () => { getSimulateSubagents: vi.fn().mockReturnValue(false), getSimulateSkills: vi.fn().mockReturnValue(false), isPreviewMode: vi.fn().mockReturnValue(false), + getRulesyncDir: vi.fn().mockReturnValue(process.cwd()), }; vi.mocked(intersection).mockImplementation((a, b) => a.filter((item) => b.includes(item))); diff --git a/src/lib/generate.ts b/src/lib/generate.ts index b7512a3f7..71de1b8df 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -249,6 +249,7 @@ async function generateRulesCore(params: { const processor = new RulesProcessor({ baseDir: baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget: toolTarget, global: config.getGlobal(), simulateCommands: config.getSimulateCommands(), @@ -304,6 +305,7 @@ async function generateIgnoreCore(params: { try { const processor = new IgnoreProcessor({ baseDir: baseDir === process.cwd() ? "." : baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget, dryRun: config.isPreviewMode(), logger, @@ -356,6 +358,7 @@ async function generateMcpCore(params: { const processor = new McpProcessor({ baseDir: baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget: toolTarget, global: config.getGlobal(), dryRun: config.isPreviewMode(), @@ -406,6 +409,7 @@ async function generateCommandsCore(params: { const processor = new CommandsProcessor({ baseDir: baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget: toolTarget, global: config.getGlobal(), dryRun: config.isPreviewMode(), @@ -456,6 +460,7 @@ async function generateSubagentsCore(params: { const processor = new SubagentsProcessor({ baseDir: baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget: toolTarget, global: config.getGlobal(), dryRun: config.isPreviewMode(), @@ -507,6 +512,7 @@ async function generateSkillsCore(params: { const processor = new SkillsProcessor({ baseDir: baseDir, + rulesyncDir: config.getRulesyncDir(), toolTarget: toolTarget, global: config.getGlobal(), dryRun: config.isPreviewMode(), diff --git a/src/lib/import.test.ts b/src/lib/import.test.ts index d1fe22246..816f23642 100644 --- a/src/lib/import.test.ts +++ b/src/lib/import.test.ts @@ -30,6 +30,7 @@ describe("importFromTool", () => { getFeatures: ReturnType; getFeatureOptions: ReturnType; getGlobal: ReturnType; + getRulesyncDir: ReturnType; }; beforeEach(() => { @@ -40,6 +41,7 @@ describe("importFromTool", () => { getFeatures: vi.fn().mockReturnValue(["rules"]), getFeatureOptions: vi.fn().mockReturnValue(undefined), getGlobal: vi.fn().mockReturnValue(false), + getRulesyncDir: vi.fn().mockReturnValue(process.cwd()), }; vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode"]); diff --git a/src/test-utils/test-directories.ts b/src/test-utils/test-directories.ts index 05d96ccbc..09267160b 100644 --- a/src/test-utils/test-directories.ts +++ b/src/test-utils/test-directories.ts @@ -14,7 +14,9 @@ export async function setupTestDirectory({ home }: { home: boolean } = { home: f testDir: string; cleanup: () => Promise; }> { - const testsDir = join(originalCwd, "tmp", "tests"); + // Use TMPDIR environment variable to ensure writes are sandboxed-friendly + const root = process.env.TMPDIR || originalCwd; + const testsDir = join(root, "tmp", "tests"); const testDir = home ? join(testsDir, "home", randomString(16)) : join(testsDir, "projects", randomString(16)); diff --git a/src/types/dir-feature-processor.ts b/src/types/dir-feature-processor.ts index 705982a4f..66a2cc9c9 100644 --- a/src/types/dir-feature-processor.ts +++ b/src/types/dir-feature-processor.ts @@ -16,22 +16,26 @@ import { ToolTarget } from "./tool-targets.js"; export abstract class DirFeatureProcessor { protected readonly baseDir: string; + protected readonly rulesyncDir: string; protected readonly dryRun: boolean; protected readonly avoidBlockScalars: boolean; protected readonly logger: Logger; constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), dryRun = false, avoidBlockScalars = false, logger, }: { baseDir?: string; + rulesyncDir?: string; dryRun?: boolean; avoidBlockScalars?: boolean; logger: Logger; }) { this.baseDir = baseDir; + this.rulesyncDir = rulesyncDir; this.dryRun = dryRun; this.avoidBlockScalars = avoidBlockScalars; this.logger = logger; diff --git a/src/types/feature-processor.ts b/src/types/feature-processor.ts index 01004f1a6..20be3afaf 100644 --- a/src/types/feature-processor.ts +++ b/src/types/feature-processor.ts @@ -14,19 +14,23 @@ import { ToolTarget } from "./tool-targets.js"; export abstract class FeatureProcessor { protected readonly baseDir: string; + protected readonly rulesyncDir: string; protected readonly dryRun: boolean; protected readonly logger: Logger; constructor({ baseDir = process.cwd(), + rulesyncDir = process.cwd(), dryRun = false, logger, }: { baseDir?: string; + rulesyncDir?: string; dryRun?: boolean; logger: Logger; }) { this.baseDir = baseDir; + this.rulesyncDir = rulesyncDir; this.dryRun = dryRun; this.logger = logger; } diff --git a/src/utils/file.ts b/src/utils/file.ts index 772f513db..1a9b30c61 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,6 +1,6 @@ import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { kebabCase } from "es-toolkit"; import { globbySync } from "globby"; @@ -284,7 +284,11 @@ export function validateBaseDir(baseDir: string): void { throw new Error("baseDir cannot be an empty string"); } - checkPathTraversal({ relativePath: baseDir, intendedRootDir: process.cwd() }); + // Traversal check only applies to relative paths; absolute paths are + // explicitly provided by the caller and may point anywhere on the filesystem. + if (!isAbsolute(baseDir)) { + checkPathTraversal({ relativePath: baseDir, intendedRootDir: process.cwd() }); + } } /**