rpr-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
rpr/commands/map.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from rpr.cli import app
|
|
9
|
+
from rpr.map.architecture import assess_architecture
|
|
10
|
+
from rpr.map.chains import build_diagnostic_chains, build_representative_chains
|
|
11
|
+
from rpr.map.classifier import classify
|
|
12
|
+
from rpr.map.coverage import build_coverage_map
|
|
13
|
+
from rpr.map.dependencies import build_external_dependencies
|
|
14
|
+
from rpr.map.extractor import analyze_project, build_raw_import_inventory
|
|
15
|
+
from rpr.map.output import write_map
|
|
16
|
+
from rpr.map.responsibility import build_responsibility_summaries
|
|
17
|
+
from rpr.map.topology import build_directory_topology
|
|
18
|
+
from rpr.ui.console import console
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def map(
|
|
23
|
+
root: Annotated[
|
|
24
|
+
Path,
|
|
25
|
+
typer.Option("--root", help="Directory to analyze."),
|
|
26
|
+
] = Path("."),
|
|
27
|
+
output: Annotated[
|
|
28
|
+
Path,
|
|
29
|
+
typer.Option("--output", help="Where to write the map.", dir_okay=False),
|
|
30
|
+
] = Path("code-map.md"),
|
|
31
|
+
dry_run: Annotated[
|
|
32
|
+
bool, typer.Option("--dry-run", help="Preview actions without writing files.")
|
|
33
|
+
] = False,
|
|
34
|
+
details: Annotated[
|
|
35
|
+
bool,
|
|
36
|
+
typer.Option(
|
|
37
|
+
"--details", help="Show all files and individual node edges in the diagram."
|
|
38
|
+
),
|
|
39
|
+
] = False,
|
|
40
|
+
focus: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option("--focus", help="Repo-relative directory to drill down into."),
|
|
43
|
+
] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Generate a structured code map of a project."""
|
|
46
|
+
resolved_root = root.resolve()
|
|
47
|
+
|
|
48
|
+
if not resolved_root.exists() or not resolved_root.is_dir():
|
|
49
|
+
console.print(
|
|
50
|
+
f"[bold red]Error:[/] --root '{root}' does not exist or is not a directory."
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
if focus is not None:
|
|
55
|
+
focus = focus.replace("\\", "/").strip("/")
|
|
56
|
+
|
|
57
|
+
graph = analyze_project(resolved_root)
|
|
58
|
+
classified = classify(graph)
|
|
59
|
+
topology = build_directory_topology(graph, resolved_root)
|
|
60
|
+
assessment = assess_architecture(graph, topology)
|
|
61
|
+
chains = build_representative_chains(graph, topology, assessment)
|
|
62
|
+
diagnostic_chains = (
|
|
63
|
+
build_diagnostic_chains(graph, topology, assessment) if details else None
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
raw_imports = build_raw_import_inventory(graph)
|
|
67
|
+
ext_deps = build_external_dependencies(graph, raw_imports, topology)
|
|
68
|
+
responsibilities = build_responsibility_summaries(topology, classified, resolved_root)
|
|
69
|
+
coverage = build_coverage_map(graph, classified, topology)
|
|
70
|
+
|
|
71
|
+
if focus is not None and focus not in topology.nodes:
|
|
72
|
+
console.print(
|
|
73
|
+
f"[bold red]Error:[/] --focus '{focus}' is not a directory in the analyzed project."
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
if dry_run:
|
|
78
|
+
console.print(f"Root to scan : {resolved_root}", soft_wrap=True)
|
|
79
|
+
console.print(f"Output path : {output}", soft_wrap=True)
|
|
80
|
+
write_map(
|
|
81
|
+
graph,
|
|
82
|
+
classified,
|
|
83
|
+
output,
|
|
84
|
+
resolved_root,
|
|
85
|
+
dry_run=True,
|
|
86
|
+
details=details,
|
|
87
|
+
topology=topology,
|
|
88
|
+
assessment=assessment,
|
|
89
|
+
chains=chains,
|
|
90
|
+
focus=focus,
|
|
91
|
+
diagnostic_chains=diagnostic_chains,
|
|
92
|
+
ext_deps=ext_deps,
|
|
93
|
+
responsibilities=responsibilities,
|
|
94
|
+
coverage=coverage,
|
|
95
|
+
)
|
|
96
|
+
console.print("No files written (dry-run mode).", soft_wrap=True)
|
|
97
|
+
raise typer.Exit(0)
|
|
98
|
+
|
|
99
|
+
write_map(
|
|
100
|
+
graph,
|
|
101
|
+
classified,
|
|
102
|
+
output,
|
|
103
|
+
resolved_root,
|
|
104
|
+
details=details,
|
|
105
|
+
topology=topology,
|
|
106
|
+
assessment=assessment,
|
|
107
|
+
chains=chains,
|
|
108
|
+
focus=focus,
|
|
109
|
+
diagnostic_chains=diagnostic_chains,
|
|
110
|
+
ext_deps=ext_deps,
|
|
111
|
+
responsibilities=responsibilities,
|
|
112
|
+
coverage=coverage,
|
|
113
|
+
)
|
rpr/commands/settings.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rpr.cli import app
|
|
7
|
+
from rpr.ui.theme import SETTING_DEFINITIONS, THEMES, settings_manager, theme_manager
|
|
8
|
+
from rpr.ui.console import console, refresh_console
|
|
9
|
+
from rpr.ui.renderers import render_heading, render_info, render_success, render_table
|
|
10
|
+
|
|
11
|
+
settings_app = typer.Typer(help="Manage RPR settings and themes.")
|
|
12
|
+
app.add_typer(settings_app, name="settings")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _render_settings_overview() -> None:
|
|
16
|
+
rows = []
|
|
17
|
+
for definition, value in settings_manager.describe_settings():
|
|
18
|
+
choices = ", ".join(definition.choices) if definition.choices else "true, false"
|
|
19
|
+
rows.append([definition.key, value, choices, definition.description])
|
|
20
|
+
render_table("RPR Settings", ["Key", "Value", "Allowed", "Description"], rows)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _set_setting_value(key: str, value: str) -> None:
|
|
24
|
+
try:
|
|
25
|
+
settings_manager.set_value(key, value)
|
|
26
|
+
except KeyError:
|
|
27
|
+
console.print(f"[error]Error:[/] Unknown setting: {key}")
|
|
28
|
+
raise typer.Exit(1) from None
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
console.print(f"[error]Error:[/] {exc}")
|
|
31
|
+
raise typer.Exit(1) from exc
|
|
32
|
+
|
|
33
|
+
if key == "theme":
|
|
34
|
+
refresh_console()
|
|
35
|
+
render_success(
|
|
36
|
+
f"Updated [bold]{key}[/] to [bold]{settings_manager.get_value(key)}[/]"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@settings_app.callback(invoke_without_command=True)
|
|
41
|
+
def settings_root(ctx: typer.Context) -> None:
|
|
42
|
+
"""Inspect persisted RPR settings."""
|
|
43
|
+
if ctx.invoked_subcommand is None:
|
|
44
|
+
_render_settings_overview()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@settings_app.command("list")
|
|
48
|
+
def list_settings() -> None:
|
|
49
|
+
"""List all persisted settings."""
|
|
50
|
+
_render_settings_overview()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@settings_app.command("get")
|
|
54
|
+
def get_setting(
|
|
55
|
+
key: Annotated[str, typer.Argument(help="Setting key to inspect.")],
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Read a single persisted setting."""
|
|
58
|
+
if key not in SETTING_DEFINITIONS:
|
|
59
|
+
console.print(f"[error]Error:[/] Unknown setting: {key}")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
render_info(f"{key}: [bold]{settings_manager.get_value(key)}[/]")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@settings_app.command("set")
|
|
65
|
+
def set_setting(
|
|
66
|
+
key: Annotated[str, typer.Argument(help="Setting key to update.")],
|
|
67
|
+
value: Annotated[str, typer.Argument(help="New value for the setting.")],
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Update a persisted setting."""
|
|
70
|
+
_set_setting_value(key, value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@settings_app.command("theme")
|
|
74
|
+
def set_theme(
|
|
75
|
+
name: Annotated[
|
|
76
|
+
str | None, typer.Argument(help="The name of the theme to use.")
|
|
77
|
+
] = None,
|
|
78
|
+
list_themes: Annotated[
|
|
79
|
+
bool, typer.Option("--list", "-l", help="List all available themes.")
|
|
80
|
+
] = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Get or set the terminal UI theme."""
|
|
83
|
+
if list_themes:
|
|
84
|
+
render_heading("Available Themes")
|
|
85
|
+
current = theme_manager.current_theme_name
|
|
86
|
+
for theme_name in THEMES:
|
|
87
|
+
star = "[success]*[/success] " if theme_name == current else " "
|
|
88
|
+
console.print(f"{star}{theme_name}")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if name is None:
|
|
92
|
+
render_info(f"Current theme: [bold]{theme_manager.current_theme_name}[/]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
settings_manager.set_value("theme", name)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
console.print(f"[error]Error:[/] {exc}")
|
|
99
|
+
raise typer.Exit(1) from exc
|
|
100
|
+
|
|
101
|
+
refresh_console()
|
|
102
|
+
render_success(f"Theme set to: [bold]{name}[/]")
|
rpr/commands/sync.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from rpr.cli import sync_app
|
|
10
|
+
from rpr.context import RunContext
|
|
11
|
+
from rpr.generators.base import SyncConfig, load_rules
|
|
12
|
+
from rpr.generators.claude import generate_claude_rules
|
|
13
|
+
from rpr.generators.copilot import generate_copilot_rules
|
|
14
|
+
from rpr.generators.cursor import generate_cursor_rules
|
|
15
|
+
from rpr.generators.gemini import generate_gemini_rules
|
|
16
|
+
from rpr.workspace import derive_variables
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
TARGET_GENERATORS = {
|
|
21
|
+
"cursor": generate_cursor_rules,
|
|
22
|
+
"copilot": generate_copilot_rules,
|
|
23
|
+
"claude": generate_claude_rules,
|
|
24
|
+
"gemini": generate_gemini_rules,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@sync_app.command("rules")
|
|
29
|
+
def rules(
|
|
30
|
+
target: Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--target", "-t", help="Sync only one target: cursor, copilot, or claude."
|
|
34
|
+
),
|
|
35
|
+
] = None,
|
|
36
|
+
dry_run: Annotated[
|
|
37
|
+
bool, typer.Option("--dry-run", help="Preview changes without writing.")
|
|
38
|
+
] = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Sync AI instructions to Cursor, Copilot, and Claude Code."""
|
|
41
|
+
ctx = RunContext(dry_run=dry_run, project_dir=Path.cwd())
|
|
42
|
+
config_path = ctx.project_dir / ".rpr.yaml"
|
|
43
|
+
|
|
44
|
+
config = SyncConfig.load(config_path)
|
|
45
|
+
|
|
46
|
+
if not config.source:
|
|
47
|
+
default_source = Path(__file__).parent.parent / "scaffolds" / "instructions"
|
|
48
|
+
if default_source.exists():
|
|
49
|
+
config.source = str(default_source)
|
|
50
|
+
else:
|
|
51
|
+
config.source = typer.prompt(
|
|
52
|
+
"Path to rules source directory",
|
|
53
|
+
default=str(default_source),
|
|
54
|
+
)
|
|
55
|
+
if not dry_run:
|
|
56
|
+
config.save(config_path)
|
|
57
|
+
console.print(f"[bold green]Saved:[/] {config_path}")
|
|
58
|
+
|
|
59
|
+
source_dir = Path(config.source).expanduser().resolve()
|
|
60
|
+
if not source_dir.exists():
|
|
61
|
+
console.print(f"[bold red]Error:[/] Source directory not found: {source_dir}")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
if not config.variables:
|
|
65
|
+
console.print("[bold yellow]Warning:[/] No variables found in .rpr.yaml.")
|
|
66
|
+
project_name = typer.prompt("Project name", default=ctx.project_dir.name)
|
|
67
|
+
config.variables = derive_variables(project_name)
|
|
68
|
+
if not dry_run:
|
|
69
|
+
config.save(config_path)
|
|
70
|
+
console.print(f"[bold green]Updated variables in:[/] {config_path}")
|
|
71
|
+
|
|
72
|
+
all_rules = load_rules(source_dir)
|
|
73
|
+
if not all_rules:
|
|
74
|
+
# Fallback to source_dir if no "instructions" subdir exists
|
|
75
|
+
all_rules = load_rules(source_dir)
|
|
76
|
+
|
|
77
|
+
filtered = config.filter_rules(all_rules)
|
|
78
|
+
|
|
79
|
+
if not filtered:
|
|
80
|
+
console.print("[bold yellow]Warning:[/] No rules found matching the config.")
|
|
81
|
+
raise typer.Exit(0)
|
|
82
|
+
|
|
83
|
+
console.print(f"\n[bold]Syncing {len(filtered)} rules[/]\n")
|
|
84
|
+
|
|
85
|
+
targets = [target] if target else config.targets
|
|
86
|
+
|
|
87
|
+
for t in targets:
|
|
88
|
+
if t not in TARGET_GENERATORS:
|
|
89
|
+
console.print(
|
|
90
|
+
f"[bold red]Error:[/] Unknown target: {t}. Valid: cursor, copilot, claude, gemini"
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
console.print(f"[bold]Target:[/] {t}")
|
|
95
|
+
TARGET_GENERATORS[t](filtered, ctx.project_dir, config.variables, ctx)
|
|
96
|
+
|
|
97
|
+
console.print(f"\n[bold green]Done![/] Rules synced for: {', '.join(targets)}")
|
rpr/context.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rpr.ui.console import console
|
|
9
|
+
from rpr.ui.markdown import DEFAULT_MARKDOWN_PREVIEW_LINES, render_markdown
|
|
10
|
+
from rpr.ui.renderers import render_command_start, render_file_preview
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _should_preview_markdown(path: Path, preview_markdown: bool) -> bool:
|
|
14
|
+
return preview_markdown and path.suffix.lower() in {".md", ".mdc"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _render_markdown_file_preview(
|
|
18
|
+
path: Path,
|
|
19
|
+
content: str,
|
|
20
|
+
*,
|
|
21
|
+
is_dry_run: bool,
|
|
22
|
+
is_update: bool,
|
|
23
|
+
title: str | None,
|
|
24
|
+
max_lines: int | None,
|
|
25
|
+
) -> None:
|
|
26
|
+
if is_dry_run:
|
|
27
|
+
action = "update" if is_update else "create"
|
|
28
|
+
console.print(
|
|
29
|
+
f"\n[dry_run][DRY RUN][/] Would {action}: [path]{path}[/]", soft_wrap=True
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
render_markdown(
|
|
33
|
+
content,
|
|
34
|
+
mode="preview",
|
|
35
|
+
title=title or path.name,
|
|
36
|
+
max_lines=max_lines,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_dependency_groups(
|
|
41
|
+
*, runtime: list[str] | None = None, dev: list[str] | None = None
|
|
42
|
+
) -> str:
|
|
43
|
+
groups: list[str] = []
|
|
44
|
+
if runtime:
|
|
45
|
+
groups.append(f"runtime=[{', '.join(runtime)}]")
|
|
46
|
+
if dev:
|
|
47
|
+
groups.append(f"dev=[{', '.join(dev)}]")
|
|
48
|
+
return " ".join(groups)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RunContext:
|
|
53
|
+
dry_run: bool = False
|
|
54
|
+
project_dir: Path = field(default_factory=Path.cwd)
|
|
55
|
+
|
|
56
|
+
def preview_dependencies(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
manager: str,
|
|
60
|
+
target: Path | str,
|
|
61
|
+
runtime: list[str] | None = None,
|
|
62
|
+
dev: list[str] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
scope = "Would install" if self.dry_run else "Installing"
|
|
65
|
+
groups = _format_dependency_groups(runtime=runtime, dev=dev)
|
|
66
|
+
message = f"{scope} {manager} dependencies for {target}"
|
|
67
|
+
if groups:
|
|
68
|
+
message = f"{message}: {groups}"
|
|
69
|
+
console.print(
|
|
70
|
+
f"[DRY RUN] {message}" if self.dry_run else message,
|
|
71
|
+
soft_wrap=True,
|
|
72
|
+
markup=False,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def run_command(
|
|
76
|
+
self,
|
|
77
|
+
cmd: list[str],
|
|
78
|
+
*,
|
|
79
|
+
cwd: Path | str | None = None,
|
|
80
|
+
env: dict[str, str] | None = None,
|
|
81
|
+
capture: bool = False,
|
|
82
|
+
supports_dry_run: bool = False,
|
|
83
|
+
) -> subprocess.CompletedProcess[str]:
|
|
84
|
+
resolved_cwd = str(cwd) if cwd else str(self.project_dir)
|
|
85
|
+
env_prefix = " ".join(f"{key}={value}" for key, value in (env or {}).items())
|
|
86
|
+
|
|
87
|
+
display_cmd = " ".join(cmd)
|
|
88
|
+
if env_prefix:
|
|
89
|
+
display_cmd = f"{env_prefix} {display_cmd}"
|
|
90
|
+
|
|
91
|
+
if self.dry_run and not supports_dry_run:
|
|
92
|
+
render_command_start(display_cmd, resolved_cwd, is_dry_run=True)
|
|
93
|
+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
|
94
|
+
|
|
95
|
+
effective_cmd = (
|
|
96
|
+
cmd + ["--dry-run"] if (self.dry_run and supports_dry_run) else cmd
|
|
97
|
+
)
|
|
98
|
+
if self.dry_run and supports_dry_run:
|
|
99
|
+
display_cmd = " ".join(effective_cmd)
|
|
100
|
+
if env_prefix:
|
|
101
|
+
display_cmd = f"{env_prefix} {display_cmd}"
|
|
102
|
+
|
|
103
|
+
render_command_start(display_cmd, resolved_cwd, is_dry_run=False)
|
|
104
|
+
|
|
105
|
+
effective_env = None
|
|
106
|
+
if env:
|
|
107
|
+
effective_env = os.environ.copy()
|
|
108
|
+
effective_env.update(env)
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
effective_cmd,
|
|
111
|
+
cwd=resolved_cwd,
|
|
112
|
+
env=effective_env,
|
|
113
|
+
capture_output=capture,
|
|
114
|
+
text=True,
|
|
115
|
+
check=True,
|
|
116
|
+
)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
def write_file(
|
|
120
|
+
self,
|
|
121
|
+
path: Path,
|
|
122
|
+
content: str,
|
|
123
|
+
*,
|
|
124
|
+
preview_markdown: bool = False,
|
|
125
|
+
markdown_title: str | None = None,
|
|
126
|
+
markdown_max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
|
|
127
|
+
) -> None:
|
|
128
|
+
if self.dry_run:
|
|
129
|
+
if _should_preview_markdown(path, preview_markdown):
|
|
130
|
+
_render_markdown_file_preview(
|
|
131
|
+
path,
|
|
132
|
+
content,
|
|
133
|
+
is_dry_run=True,
|
|
134
|
+
is_update=False,
|
|
135
|
+
title=markdown_title,
|
|
136
|
+
max_lines=markdown_max_lines,
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
render_file_preview(path, content, is_dry_run=True, is_update=False)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
path.write_text(content)
|
|
144
|
+
render_file_preview(path, content, is_dry_run=False, is_update=False)
|
|
145
|
+
if _should_preview_markdown(path, preview_markdown):
|
|
146
|
+
_render_markdown_file_preview(
|
|
147
|
+
path,
|
|
148
|
+
content,
|
|
149
|
+
is_dry_run=False,
|
|
150
|
+
is_update=False,
|
|
151
|
+
title=markdown_title,
|
|
152
|
+
max_lines=markdown_max_lines,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def ensure_dir(self, path: Path) -> None:
|
|
156
|
+
if self.dry_run:
|
|
157
|
+
from rpr.ui.console import console
|
|
158
|
+
|
|
159
|
+
console.print(
|
|
160
|
+
f"[dry_run][DRY RUN][/] Would create directory: [path]{path}[/]",
|
|
161
|
+
soft_wrap=True,
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
from rpr.ui.console import console
|
|
167
|
+
|
|
168
|
+
console.print(f"[success]Created:[/] [path]{path}/[/]")
|
|
169
|
+
|
|
170
|
+
def update_file(
|
|
171
|
+
self,
|
|
172
|
+
path: Path,
|
|
173
|
+
content: str,
|
|
174
|
+
*,
|
|
175
|
+
preview_markdown: bool = False,
|
|
176
|
+
markdown_title: str | None = None,
|
|
177
|
+
markdown_max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
|
|
178
|
+
) -> None:
|
|
179
|
+
if self.dry_run:
|
|
180
|
+
if _should_preview_markdown(path, preview_markdown):
|
|
181
|
+
_render_markdown_file_preview(
|
|
182
|
+
path,
|
|
183
|
+
content,
|
|
184
|
+
is_dry_run=True,
|
|
185
|
+
is_update=True,
|
|
186
|
+
title=markdown_title,
|
|
187
|
+
max_lines=markdown_max_lines,
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
render_file_preview(path, content, is_dry_run=True, is_update=True)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
path.write_text(content)
|
|
194
|
+
render_file_preview(path, content, is_dry_run=False, is_update=True)
|
|
195
|
+
if _should_preview_markdown(path, preview_markdown):
|
|
196
|
+
_render_markdown_file_preview(
|
|
197
|
+
path,
|
|
198
|
+
content,
|
|
199
|
+
is_dry_run=False,
|
|
200
|
+
is_update=True,
|
|
201
|
+
title=markdown_title,
|
|
202
|
+
max_lines=markdown_max_lines,
|
|
203
|
+
)
|
|
File without changes
|
rpr/generators/base.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Rule:
|
|
12
|
+
id: str
|
|
13
|
+
content: str
|
|
14
|
+
description: str = ""
|
|
15
|
+
apply_to: str = "**"
|
|
16
|
+
rule_type: str = "instruction"
|
|
17
|
+
language: str = "all"
|
|
18
|
+
source_path: Path | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_rules(source_dir: Path) -> list[Rule]:
|
|
22
|
+
"""Load all markdown rules from a source directory, parsing YAML frontmatter."""
|
|
23
|
+
rules: list[Rule] = []
|
|
24
|
+
|
|
25
|
+
for md_file in sorted(source_dir.rglob("*.md")):
|
|
26
|
+
if ".venv" in md_file.parts:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
text = md_file.read_text()
|
|
30
|
+
rule_id = md_file.stem.replace(".instructions", "")
|
|
31
|
+
frontmatter: dict = {}
|
|
32
|
+
body = text
|
|
33
|
+
|
|
34
|
+
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
|
|
35
|
+
if fm_match:
|
|
36
|
+
try:
|
|
37
|
+
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
|
|
38
|
+
except yaml.YAMLError:
|
|
39
|
+
frontmatter = {}
|
|
40
|
+
body = fm_match.group(2)
|
|
41
|
+
|
|
42
|
+
first_heading = ""
|
|
43
|
+
heading_match = re.search(r"^#+\s+(.+)$", body, re.MULTILINE)
|
|
44
|
+
if heading_match:
|
|
45
|
+
first_heading = heading_match.group(1).strip()
|
|
46
|
+
|
|
47
|
+
rules.append(
|
|
48
|
+
Rule(
|
|
49
|
+
id=rule_id,
|
|
50
|
+
content=body.strip(),
|
|
51
|
+
description=frontmatter.get("description", first_heading),
|
|
52
|
+
apply_to=frontmatter.get("applyTo", "**"),
|
|
53
|
+
rule_type=frontmatter.get("type", "instruction"),
|
|
54
|
+
language=frontmatter.get("language", "all"),
|
|
55
|
+
source_path=md_file,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return rules
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def interpolate(text: str, variables: dict[str, str]) -> str:
|
|
63
|
+
"""Replace {variable} placeholders with values from the variables dict."""
|
|
64
|
+
if not variables:
|
|
65
|
+
return text
|
|
66
|
+
for key, value in variables.items():
|
|
67
|
+
text = text.replace(f"{{{key}}}", value)
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SyncConfig:
|
|
73
|
+
source: str = ""
|
|
74
|
+
targets: list[str] = field(default_factory=lambda: ["cursor", "copilot", "claude"])
|
|
75
|
+
include: list[str] = field(default_factory=list)
|
|
76
|
+
exclude: list[str] = field(default_factory=list)
|
|
77
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def load(cls, path: Path) -> SyncConfig:
|
|
81
|
+
if not path.exists():
|
|
82
|
+
return cls()
|
|
83
|
+
try:
|
|
84
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
85
|
+
except yaml.YAMLError:
|
|
86
|
+
data = {}
|
|
87
|
+
return cls(
|
|
88
|
+
source=data.get("source", ""),
|
|
89
|
+
targets=data.get("targets", ["cursor", "copilot", "claude"]),
|
|
90
|
+
include=data.get("include", []),
|
|
91
|
+
exclude=data.get("exclude", []),
|
|
92
|
+
variables=data.get("variables", {}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def save(self, path: Path) -> None:
|
|
96
|
+
data = {
|
|
97
|
+
"source": self.source,
|
|
98
|
+
"targets": self.targets,
|
|
99
|
+
"include": self.include,
|
|
100
|
+
"exclude": self.exclude,
|
|
101
|
+
"variables": self.variables,
|
|
102
|
+
}
|
|
103
|
+
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
104
|
+
|
|
105
|
+
def filter_rules(self, rules: list[Rule]) -> list[Rule]:
|
|
106
|
+
if self.include:
|
|
107
|
+
rules = [r for r in rules if r.id in self.include]
|
|
108
|
+
if self.exclude:
|
|
109
|
+
rules = [r for r in rules if r.id not in self.exclude]
|
|
110
|
+
return rules
|
rpr/generators/claude.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rpr.context import RunContext
|
|
6
|
+
from rpr.generators.base import Rule, interpolate
|
|
7
|
+
|
|
8
|
+
HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_claude_rules(
|
|
12
|
+
rules: list[Rule],
|
|
13
|
+
project_dir: Path,
|
|
14
|
+
variables: dict[str, str],
|
|
15
|
+
ctx: RunContext,
|
|
16
|
+
) -> None:
|
|
17
|
+
sections: list[str] = [HEADER.strip()]
|
|
18
|
+
|
|
19
|
+
for rule in rules:
|
|
20
|
+
content = interpolate(rule.content, variables)
|
|
21
|
+
section_header = (
|
|
22
|
+
f"## {rule.description}" if rule.description else f"## {rule.id}"
|
|
23
|
+
)
|
|
24
|
+
sections.append(f"{section_header}\n\n{content}")
|
|
25
|
+
|
|
26
|
+
full_content = "\n\n---\n\n".join(sections) + "\n"
|
|
27
|
+
ctx.write_file(
|
|
28
|
+
project_dir / "CLAUDE.md",
|
|
29
|
+
full_content,
|
|
30
|
+
preview_markdown=True,
|
|
31
|
+
markdown_title="Claude Instructions Preview",
|
|
32
|
+
markdown_max_lines=80,
|
|
33
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rpr.context import RunContext
|
|
6
|
+
from rpr.generators.base import Rule, interpolate
|
|
7
|
+
|
|
8
|
+
HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_copilot_rules(
|
|
12
|
+
rules: list[Rule],
|
|
13
|
+
project_dir: Path,
|
|
14
|
+
variables: dict[str, str],
|
|
15
|
+
ctx: RunContext,
|
|
16
|
+
) -> None:
|
|
17
|
+
instructions_dir = project_dir / ".github"
|
|
18
|
+
ctx.ensure_dir(instructions_dir)
|
|
19
|
+
|
|
20
|
+
global_rules = [r for r in rules if r.apply_to == "**"]
|
|
21
|
+
|
|
22
|
+
# Copilot custom instructions only support a single file.
|
|
23
|
+
# Scoped rules are ignored here as they don't map cleanly to a global context.
|
|
24
|
+
|
|
25
|
+
if global_rules:
|
|
26
|
+
global_content = HEADER
|
|
27
|
+
for rule in global_rules:
|
|
28
|
+
content = interpolate(rule.content, variables)
|
|
29
|
+
global_content += f"\n{content}\n\n---\n"
|
|
30
|
+
ctx.write_file(
|
|
31
|
+
instructions_dir / "copilot-instructions.md",
|
|
32
|
+
global_content,
|
|
33
|
+
preview_markdown=True,
|
|
34
|
+
markdown_title="Copilot Instructions Preview",
|
|
35
|
+
markdown_max_lines=80,
|
|
36
|
+
)
|