diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5c153f2ac..d57864290 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -50,6 +50,14 @@ Example: "simulateSubagents": false, // Generate simulated subagents "simulateSkills": false, // Generate simulated skills + // Naming for command files flattened for tools without subdirectory + // command support (e.g. Cursor): "basename" (default) keeps only the + // filename, so `pj/test.md` and `ops/test.md` collide and the last one + // wins; "path" joins the directory segments into the filename + // (`pj/test.md` -> `pj-test.md`), keeping flattened names unique. + // Tools that support subdirectories (e.g. Claude Code) are unaffected. + "flattenedCommandNaming": "basename", + // When true (default), `rulesync gitignore` only emits entries for the // tools listed in `targets`. Set to false to emit entries for all supported // tools regardless of `targets`. diff --git a/skills/rulesync/configuration.md b/skills/rulesync/configuration.md index 5c153f2ac..d57864290 100644 --- a/skills/rulesync/configuration.md +++ b/skills/rulesync/configuration.md @@ -50,6 +50,14 @@ Example: "simulateSubagents": false, // Generate simulated subagents "simulateSkills": false, // Generate simulated skills + // Naming for command files flattened for tools without subdirectory + // command support (e.g. Cursor): "basename" (default) keeps only the + // filename, so `pj/test.md` and `ops/test.md` collide and the last one + // wins; "path" joins the directory segments into the filename + // (`pj/test.md` -> `pj-test.md`), keeping flattened names unique. + // Tools that support subdirectories (e.g. Claude Code) are unaffected. + "flattenedCommandNaming": "basename", + // When true (default), `rulesync gitignore` only emits entries for the // tools listed in `targets`. Set to false to emit entries for all supported // tools regardless of `targets`. diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index ac855e351..ee40ce5a6 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -54,6 +54,7 @@ describe("generateCommand", () => { getSimulateCommands: vi.fn().mockReturnValue(false), getSimulateSubagents: vi.fn().mockReturnValue(false), getSimulateSkills: vi.fn().mockReturnValue(false), + getFlattenedCommandNaming: vi.fn().mockReturnValue("basename"), getDryRun: vi.fn().mockReturnValue(false), getCheck: vi.fn().mockReturnValue(false), isPreviewMode: vi.fn().mockReturnValue(false), diff --git a/src/config/config-resolver.ts b/src/config/config-resolver.ts index 3ec122b1d..04fcbd48d 100644 --- a/src/config/config-resolver.ts +++ b/src/config/config-resolver.ts @@ -33,10 +33,11 @@ import { import type { OutputRoots } from "./config.js"; /** - * CLI-resolvable params exclude `sources` — sources are config-file-only. + * CLI-resolvable params exclude `sources` and `flattenedCommandNaming` — they + * are config-file-only. */ export type ConfigResolverResolveParams = Partial< - Omit & { + Omit & { configPath: string; } >; @@ -62,6 +63,7 @@ const getDefaults = (): ConfigDefaults => ({ simulateCommands: false, simulateSubagents: false, simulateSkills: false, + flattenedCommandNaming: "basename", gitignoreTargetsOnly: true, gitignoreDestination: "gitignore", dryRun: false, @@ -106,6 +108,7 @@ const mergeConfigs = ( simulateCommands: localConfig.simulateCommands ?? baseConfig.simulateCommands, simulateSubagents: localConfig.simulateSubagents ?? baseConfig.simulateSubagents, simulateSkills: localConfig.simulateSkills ?? baseConfig.simulateSkills, + flattenedCommandNaming: localConfig.flattenedCommandNaming ?? baseConfig.flattenedCommandNaming, gitignoreTargetsOnly: localConfig.gitignoreTargetsOnly ?? baseConfig.gitignoreTargetsOnly, gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination, dryRun: localConfig.dryRun ?? baseConfig.dryRun, @@ -362,6 +365,8 @@ export class ConfigResolver { // captured `cwd` so the value is still deterministic. inputRoot: resolvedInputRoot !== undefined ? resolve(resolvedInputRoot) : cwd, sources: configByFile.sources ?? getDefaults().sources, + flattenedCommandNaming: + configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming, configFileTargets: extractConfigFileTargets(configByFile.targets), }; const config = new Config(configParams); diff --git a/src/config/config.ts b/src/config/config.ts index 97269abc6..c0ba613d0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -7,6 +7,8 @@ import { Feature, FeatureOptions, Features, + FlattenedCommandNaming, + FlattenedCommandNamingSchema, GitignoreDestination, GitignoreDestinationSchema, isFeatureValueEnabled, @@ -103,6 +105,7 @@ export const ConfigParamsSchema = z.object({ simulateCommands: optional(z.boolean()), simulateSubagents: optional(z.boolean()), simulateSkills: optional(z.boolean()), + flattenedCommandNaming: optional(FlattenedCommandNamingSchema), gitignoreTargetsOnly: optional(z.boolean()), gitignoreDestination: optional(GitignoreDestinationSchema), dryRun: optional(z.boolean()), @@ -263,6 +266,7 @@ export class Config { private readonly simulateCommands: boolean; private readonly simulateSubagents: boolean; private readonly simulateSkills: boolean; + private readonly flattenedCommandNaming: FlattenedCommandNaming; private readonly gitignoreTargetsOnly: boolean; private readonly gitignoreDestination: GitignoreDestination; private readonly dryRun: boolean; @@ -281,6 +285,7 @@ export class Config { simulateCommands, simulateSubagents, simulateSkills, + flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, @@ -337,6 +342,7 @@ export class Config { this.simulateCommands = simulateCommands ?? false; this.simulateSubagents = simulateSubagents ?? false; this.simulateSkills = simulateSkills ?? false; + this.flattenedCommandNaming = flattenedCommandNaming ?? "basename"; this.gitignoreTargetsOnly = gitignoreTargetsOnly ?? true; this.gitignoreDestination = gitignoreDestination ?? "gitignore"; this.dryRun = dryRun ?? false; @@ -621,6 +627,10 @@ export class Config { return this.simulateCommands; } + public getFlattenedCommandNaming(): FlattenedCommandNaming { + return this.flattenedCommandNaming; + } + public getSimulateSubagents(): boolean { return this.simulateSubagents; } diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 788cb50aa..5f71506a2 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -531,6 +531,140 @@ describe("CommandsProcessor", () => { ); }); + it('should join path segments when flattenedCommandNaming is "path" (generate)', async () => { + processor = new CommandsProcessor({ + logger, + outputRoot: testDir, + toolTarget: "cursor", + flattenedCommandNaming: "path", + }); + + const nestedCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: join("update", "confluence", "docs.md"), + fileContent: "test content", + frontmatter: { + targets: ["cursor"], + description: "test description", + }, + body: "test content", + }); + + vi.mocked(CursorCommand.fromRulesyncCommand).mockReturnValue( + new CursorCommand({ + outputRoot: testDir, + relativeDirPath: join(".cursor", "commands"), + relativeFilePath: "update-confluence-docs.md", + frontmatter: {}, + body: "converted content", + }), + ); + + await processor.convertRulesyncFilesToToolFiles([nestedCommand]); + + const calledArgs = vi.mocked(CursorCommand.fromRulesyncCommand).mock.calls[0]![0]!; + expect(calledArgs.rulesyncCommand.getRelativeFilePath()).toBe("update-confluence-docs.md"); + }); + + it('should not warn about collisions when flattenedCommandNaming is "path" keeps names unique', async () => { + processor = new CommandsProcessor({ + logger, + outputRoot: testDir, + toolTarget: "cursor", + flattenedCommandNaming: "path", + }); + + const commandInPj = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: join("pj", "test.md"), + fileContent: "content from pj", + frontmatter: { + targets: ["cursor"], + description: "pj command", + }, + body: "content from pj", + }); + const commandInOps = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: join("ops", "test.md"), + fileContent: "content from ops", + frontmatter: { + targets: ["cursor"], + description: "ops command", + }, + body: "content from ops", + }); + + vi.mocked(CursorCommand.fromRulesyncCommand) + .mockReturnValueOnce( + new CursorCommand({ + outputRoot: testDir, + relativeDirPath: join(".cursor", "commands"), + relativeFilePath: "pj-test.md", + frontmatter: {}, + body: "converted from pj", + }), + ) + .mockReturnValueOnce( + new CursorCommand({ + outputRoot: testDir, + relativeDirPath: join(".cursor", "commands"), + relativeFilePath: "ops-test.md", + frontmatter: {}, + body: "converted from ops", + }), + ); + + const result = await processor.convertRulesyncFilesToToolFiles([commandInPj, commandInOps]); + + expect(result).toHaveLength(2); + expect(logger.warn).not.toHaveBeenCalled(); + const calls = vi.mocked(CursorCommand.fromRulesyncCommand).mock.calls; + expect(calls[0]![0]!.rulesyncCommand.getRelativeFilePath()).toBe("pj-test.md"); + expect(calls[1]![0]!.rulesyncCommand.getRelativeFilePath()).toBe("ops-test.md"); + }); + + it('should preserve subdirectory path for supportsSubdirectory=true tools when flattenedCommandNaming is "path"', async () => { + processor = new CommandsProcessor({ + logger, + outputRoot: testDir, + toolTarget: "claudecode", + flattenedCommandNaming: "path", + }); + + const nestedCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: join("pj", "test.md"), + fileContent: "test content", + frontmatter: { + targets: ["claudecode"], + description: "test description", + }, + body: "test content", + }); + + vi.mocked(ClaudecodeCommand.fromRulesyncCommand).mockReturnValue( + new ClaudecodeCommand({ + outputRoot: testDir, + relativeDirPath: join(".claude", "commands"), + relativeFilePath: join("pj", "test.md"), + frontmatter: { + description: "test description", + }, + body: "converted content", + }), + ); + + await processor.convertRulesyncFilesToToolFiles([nestedCommand]); + + const calledArgs = vi.mocked(ClaudecodeCommand.fromRulesyncCommand).mock.calls[0]![0]!; + expect(calledArgs.rulesyncCommand.getRelativeFilePath()).toBe(join("pj", "test.md")); + }); + it("should filter out non-rulesync command files", async () => { const mockRulesyncCommand = new RulesyncCommand({ outputRoot: testDir, diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index b5674a8f5..22bc2d259 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -4,6 +4,7 @@ import { z } from "zod/mini"; import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { FeatureProcessor } from "../../types/feature-processor.js"; +import type { FlattenedCommandNaming } from "../../types/features.js"; import { RulesyncFile } from "../../types/rulesync-file.js"; import { ToolFile } from "../../types/tool-file.js"; import { commandsProcessorToolTargetTuple } from "../../types/tool-target-tuples.js"; @@ -527,6 +528,7 @@ export class CommandsProcessor extends FeatureProcessor { private readonly toolTarget: CommandsProcessorToolTarget; private readonly global: boolean; private readonly getFactory: GetFactory; + private readonly flattenedCommandNaming: FlattenedCommandNaming; constructor({ outputRoot = process.cwd(), @@ -535,6 +537,7 @@ export class CommandsProcessor extends FeatureProcessor { global = false, getFactory = defaultGetFactory, dryRun = false, + flattenedCommandNaming = "basename", logger, }: { outputRoot?: string; @@ -543,6 +546,7 @@ export class CommandsProcessor extends FeatureProcessor { global?: boolean; getFactory?: GetFactory; dryRun?: boolean; + flattenedCommandNaming?: FlattenedCommandNaming; logger: Logger; }) { super({ outputRoot, inputRoot, dryRun, logger }); @@ -555,6 +559,7 @@ export class CommandsProcessor extends FeatureProcessor { this.toolTarget = result.data; this.global = global; this.getFactory = getFactory; + this.flattenedCommandNaming = flattenedCommandNaming; } async convertRulesyncFilesToToolFiles(rulesyncFiles: RulesyncFile[]): Promise { @@ -620,8 +625,12 @@ export class CommandsProcessor extends FeatureProcessor { } private flattenRelativeFilePath(rulesyncCommand: RulesyncCommand): RulesyncCommand { - const flatPath = basename(rulesyncCommand.getRelativeFilePath()); - if (flatPath === rulesyncCommand.getRelativeFilePath()) return rulesyncCommand; + const relativeFilePath = rulesyncCommand.getRelativeFilePath(); + const flatPath = + this.flattenedCommandNaming === "path" + ? relativeFilePath.split(/[\\/]/).join("-") + : basename(relativeFilePath); + if (flatPath === relativeFilePath) return rulesyncCommand; return rulesyncCommand.withRelativeFilePath(flatPath); } diff --git a/src/lib/generate.test.ts b/src/lib/generate.test.ts index af1c79128..674ab9ba4 100644 --- a/src/lib/generate.test.ts +++ b/src/lib/generate.test.ts @@ -130,6 +130,7 @@ describe("generate", () => { getCheck: ReturnType; getGlobal: ReturnType; getSimulateCommands: ReturnType; + getFlattenedCommandNaming: ReturnType; getSimulateSubagents: ReturnType; getSimulateSkills: ReturnType; isPreviewMode: ReturnType; @@ -149,6 +150,7 @@ describe("generate", () => { getCheck: vi.fn().mockReturnValue(false), getGlobal: vi.fn().mockReturnValue(false), getSimulateCommands: vi.fn().mockReturnValue(false), + getFlattenedCommandNaming: vi.fn().mockReturnValue("basename"), getSimulateSubagents: vi.fn().mockReturnValue(false), getSimulateSkills: vi.fn().mockReturnValue(false), isPreviewMode: vi.fn().mockReturnValue(false), diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 68604ecc5..c0b05c664 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -772,6 +772,7 @@ async function generateCommandsCore(params: { toolTarget: toolTarget, global: config.getGlobal(), dryRun: config.isPreviewMode(), + flattenedCommandNaming: config.getFlattenedCommandNaming(), logger, }); diff --git a/src/types/features.ts b/src/types/features.ts index 26066ac62..73bb6b0d5 100644 --- a/src/types/features.ts +++ b/src/types/features.ts @@ -27,6 +27,13 @@ type FeatureWithWildcard = Feature | "*"; export const GitignoreDestinationSchema = z.enum(["gitignore", "gitattributes"]); export type GitignoreDestination = z.infer; +// Naming strategy for command files flattened for tools without subdirectory +// support: "basename" keeps only the filename (`pj/test.md` -> `test.md`, +// colliding paths overwrite each other), "path" joins the directory segments +// into the filename (`pj/test.md` -> `pj-test.md`), keeping names unique. +export const FlattenedCommandNamingSchema = z.enum(["basename", "path"]); +export type FlattenedCommandNaming = z.infer; + // Free-form options object that may be passed for a specific feature. // Each tool/feature is responsible for parsing and validating its own keys. export type FeatureOptions = Record;