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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +57 to +58
"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`.
Expand Down
8 changes: 8 additions & 0 deletions skills/rulesync/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +57 to +58
"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`.
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
9 changes: 7 additions & 2 deletions src/config/config-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigParams, "sources"> & {
Omit<ConfigParams, "sources" | "flattenedCommandNaming"> & {
configPath: string;
}
>;
Expand All @@ -62,6 +63,7 @@ const getDefaults = (): ConfigDefaults => ({
simulateCommands: false,
simulateSubagents: false,
simulateSkills: false,
flattenedCommandNaming: "basename",
gitignoreTargetsOnly: true,
gitignoreDestination: "gitignore",
dryRun: false,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
Feature,
FeatureOptions,
Features,
FlattenedCommandNaming,
FlattenedCommandNamingSchema,
GitignoreDestination,
GitignoreDestinationSchema,
isFeatureValueEnabled,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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;
Expand All @@ -281,6 +285,7 @@ export class Config {
simulateCommands,
simulateSubagents,
simulateSkills,
flattenedCommandNaming,
gitignoreTargetsOnly,
gitignoreDestination,
dryRun,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -621,6 +627,10 @@ export class Config {
return this.simulateCommands;
}

public getFlattenedCommandNaming(): FlattenedCommandNaming {
return this.flattenedCommandNaming;
}

public getSimulateSubagents(): boolean {
return this.simulateSubagents;
}
Expand Down
134 changes: 134 additions & 0 deletions src/features/commands/commands-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions src/features/commands/commands-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand All @@ -535,6 +537,7 @@ export class CommandsProcessor extends FeatureProcessor {
global = false,
getFactory = defaultGetFactory,
dryRun = false,
flattenedCommandNaming = "basename",
logger,
}: {
outputRoot?: string;
Expand All @@ -543,6 +546,7 @@ export class CommandsProcessor extends FeatureProcessor {
global?: boolean;
getFactory?: GetFactory;
dryRun?: boolean;
flattenedCommandNaming?: FlattenedCommandNaming;
logger: Logger;
}) {
super({ outputRoot, inputRoot, dryRun, logger });
Expand All @@ -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<ToolFile[]> {
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ describe("generate", () => {
getCheck: ReturnType<typeof vi.fn>;
getGlobal: ReturnType<typeof vi.fn>;
getSimulateCommands: ReturnType<typeof vi.fn>;
getFlattenedCommandNaming: ReturnType<typeof vi.fn>;
getSimulateSubagents: ReturnType<typeof vi.fn>;
getSimulateSkills: ReturnType<typeof vi.fn>;
isPreviewMode: ReturnType<typeof vi.fn>;
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/lib/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@ async function generateCommandsCore(params: {
toolTarget: toolTarget,
global: config.getGlobal(),
dryRun: config.isPreviewMode(),
flattenedCommandNaming: config.getFlattenedCommandNaming(),
logger,
});

Expand Down
7 changes: 7 additions & 0 deletions src/types/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ type FeatureWithWildcard = Feature | "*";
export const GitignoreDestinationSchema = z.enum(["gitignore", "gitattributes"]);
export type GitignoreDestination = z.infer<typeof GitignoreDestinationSchema>;

// 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<typeof FlattenedCommandNamingSchema>;

// 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<string, unknown>;
Expand Down
Loading