Skip to content

Conversation

KevinFairise2
Copy link
Member

Create a new dda ai tree used for AI and Coding agent related utilities.
Implement dda ai rules sync command to automatically synchronize Cursor rules to other coding agent files.
Default usage will pick all the rules in .cursor/rules and add them in CLAUDE.md file, so that claude can use the rules defined for Cursor.

Also adds a --check-sync flag to be used in the CI to check that the rules remain synchronized.

@KevinFairise2 KevinFairise2 requested a review from a team as a code owner August 28, 2025 16:35
@KevinFairise2 KevinFairise2 force-pushed the kfairise/dda-ai-rules-sync branch 5 times, most recently from e34c12c to 746b659 Compare August 28, 2025 16:59
@KevinFairise2 KevinFairise2 force-pushed the kfairise/dda-ai-rules-sync branch from 746b659 to 406707b Compare August 28, 2025 17:04
Copy link
Contributor

@CelianR CelianR left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I'll let some thoughts on slack

Copy link
Contributor

@Ishirui Ishirui left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this ! Please ask for re-review once you've had another look :)
For the naming convention, I think ai is fine, but only if we choose to also merge all mcp commands under here (so we'd have dda ai mcp ...). @ofek what do you think ?



@dynamic_group(
short_help="AI assistant tools and utilities",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: IIRC we prefer to use verbal phrases for help texts, so maybe something like

Suggested change
short_help="AI assistant tools and utilities",
short_help="Manage and configure AI assistants / utilities",

would be better ?

"--cursor-rules-path",
"-c",
"cursor_rules_path",
type=click.Path(exists=True, path_type=Path),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should probably check that this is a directory and not a file

Suggested change
type=click.Path(exists=True, path_type=Path),
type=click.Path(exists=True, path_type=Path, file_okay=False),

)
@pass_app
def cmd(app: Application, *, cursor_rules_path: Path, sync_targets: str, check_sync: bool) -> None:
cursor_rules_dir = Path(cursor_rules_path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary ?

Suggested change
cursor_rules_dir = Path(cursor_rules_path)

Will also need to rename cursor_rules_dir -> cursor_rules_path

Comment on lines +43 to +45
def cmd(app: Application, *, cursor_rules_path: Path, sync_targets: str, check_sync: bool) -> None:
cursor_rules_dir = Path(cursor_rules_path)
targets_files = [Path(target) for target in sync_targets.split(",")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required changes with above nargs=-1 change. Also a good idea to check for len(sync_targets) > 0

Suggested change
def cmd(app: Application, *, cursor_rules_path: Path, sync_targets: str, check_sync: bool) -> None:
cursor_rules_dir = Path(cursor_rules_path)
targets_files = [Path(target) for target in sync_targets.split(",")]
def cmd(app: Application, *, cursor_rules_path: Path, sync_targets: tuple[Path, ...], check_sync: bool) -> None:
cursor_rules_dir = Path(cursor_rules_path)
if len(sync_targets) == 0:
app.display_warning("Please specify at least one sync target")
return

Will also need to rename targets_files -> sync_targets

for root, _, files in os.walk(cursor_rules_dir):
root_path = Path(root)
# More robust check for personal rules using path parts
if any(part == "personal" for part in root_path.parts):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if any(part == "personal" for part in root_path.parts):
if "personal" in root_path.parts:

for target_file in targets_files:
content = generate_content(rule_files, target_file)
if not target_file.exists() and content:
unsynced_targets.append(str(target_file))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also add a debug output in this branch, no reason why we wouldn't have one here since we have one everywhere else

Suggested change
unsynced_targets.append(str(target_file))
unsynced_targets.append(str(target_file))
app.display_debug(f"Error: {target_file} does not exist !")

for target_file in targets_files:
content = generate_content(rule_files, target_file)
try:
app.display_info(f"Syncing {len(rule_files)} rule files to {target_file}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
app.display_info(f"Syncing {len(rule_files)} rule files to {target_file}")
app.display_debug(f"Syncing {len(rule_files)} rule files to {target_file}")

Comment on lines +27 to +34
@click.option(
"--sync-targets",
"-s",
"sync_targets",
type=str,
help="Comma separated list of targets files to sync the rules to. Defaults to Claude.MD file.",
default="CLAUDE.md",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should avoid manually splitting along commas like this. Click has two methods for passing in an arbitrary number of arguments:

Option 1: option with multiple=True

By passing multiple=True you can pass the option multiple times, i.e.

dda ai rules sync -s CLAUDE.md -s AGENTs.md

So we would have:

Suggested change
@click.option(
"--sync-targets",
"-s",
"sync_targets",
type=str,
help="Comma separated list of targets files to sync the rules to. Defaults to Claude.MD file.",
default="CLAUDE.md",
)
@click.option(
"--sync-target", # Maybe we could simplify to --target as well ?
"-s",
"sync_targets",
type=click.Path(dir_okay=False, writable=True, path_type=Path),
multiple=True,
help="Target file to sync the rules to. Defaults to CLAUDE.md file. Can be passed multiple times",
default=[Path("CLAUDE.md")],
)

Option 2: argument with nargs=-1

I personally prefer this option, as it seems more inline with what we are trying to do.
Instead of using an option we should use a click.argument and pass nargs=-1. This way we do not have to use -s to specify files to sync and can pass in an arbitrary number of files on the CLI, like this:

dda ai rules sync CLAUDE.md AGENTS.md

The caveat is that we won't be able to set a default cleanly like this, as setting a default is not supported with nargs=-1. We also lose the help.
What we can do, however, is to add this to the body of our function:

if len(sync_targets) == 0:
    sync_targets = (Path("CLAUDE.md"),)

Thus we would get:

Suggested change
@click.option(
"--sync-targets",
"-s",
"sync_targets",
type=str,
help="Comma separated list of targets files to sync the rules to. Defaults to Claude.MD file.",
default="CLAUDE.md",
)
@click.parameter(
"sync_targets",
type=click.Path(dir_okay=False, writable=True, path_type=Path),
nargs=-1
)

In either case this also changes the signature of the function below, see other comment

Comment on lines +17 to +56
@pytest.fixture(name="create_temp_file_or_dir")
def fixt_create_temp_file_or_dir():
"""Fixture to create and clean up temporary files and directories."""
created_paths: list[Path] = []

def _create_temp_file_or_dir(location: Path, *, force_file: bool = False) -> None:
for parent in reversed(location.parents):
# Create and keep track of created parent directories for cleanup
if not parent.exists():
parent.mkdir()
created_paths.append(parent)

# Create the requested file or directory and keep track of it for cleanup
# Assume that if the file path does not have an extension, it is a directory
# The force_file flag can be used to override this behavior
if location.suffix == "" and not force_file:
location.mkdir()
else:
location.touch()
created_paths.append(location)

yield _create_temp_file_or_dir
for path in reversed(created_paths):
if path.exists():
if path.is_dir():
path.rmdir()
else:
path.unlink()


@pytest.fixture(name="create_cursor_rules")
def fixt_create_cursor_rules(create_temp_file_or_dir):
def _create_cursor_rules(rules_data: dict[str, str], cursor_rules_dir: Path) -> None:
"""Create cursor rule files with given content."""
for filename, content in rules_data.items():
rule_file = cursor_rules_dir / filename
create_temp_file_or_dir(rule_file, force_file=True)
rule_file.write_text(content, encoding="utf-8")

return _create_cursor_rules
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is very similar to what I implemented in tests/cli/info/owners/test_code.py. Since the same code is now reused in multiple places, could we consider making these fixtures generic and sharing them by putting them in conftest.py ?

return _create_cursor_rules


def test_sync_basic_functionality(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can merge a few of these and use pytest.mark.parametrize. What do you think ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants