llm-ide-rules 0.16.0__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.
- llm_ide_rules/__init__.py +115 -0
- llm_ide_rules/__main__.py +6 -0
- llm_ide_rules/agents/__init__.py +45 -0
- llm_ide_rules/agents/agents.py +179 -0
- llm_ide_rules/agents/antigravity.py +19 -0
- llm_ide_rules/agents/base.py +299 -0
- llm_ide_rules/agents/claude.py +228 -0
- llm_ide_rules/agents/cursor.py +232 -0
- llm_ide_rules/agents/dotagents.py +318 -0
- llm_ide_rules/agents/github.py +221 -0
- llm_ide_rules/agents/grok.py +15 -0
- llm_ide_rules/agents/opencode.py +107 -0
- llm_ide_rules/agents/vscode.py +49 -0
- llm_ide_rules/commands/config.py +46 -0
- llm_ide_rules/commands/delete.py +321 -0
- llm_ide_rules/commands/download.py +577 -0
- llm_ide_rules/commands/explode.py +364 -0
- llm_ide_rules/commands/exploded.py +33 -0
- llm_ide_rules/commands/ignores.py +127 -0
- llm_ide_rules/commands/implode.py +284 -0
- llm_ide_rules/constants.py +22 -0
- llm_ide_rules/log.py +14 -0
- llm_ide_rules/markdown_parser.py +178 -0
- llm_ide_rules/utils.py +131 -0
- llm_ide_rules/version.py +34 -0
- llm_ide_rules-0.16.0.dist-info/METADATA +171 -0
- llm_ide_rules-0.16.0.dist-info/RECORD +29 -0
- llm_ide_rules-0.16.0.dist-info/WHEEL +4 -0
- llm_ide_rules-0.16.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""LLM Rules CLI package for managing IDE prompts and rules."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
if "LOG_LEVEL" not in os.environ:
|
|
6
|
+
os.environ["LOG_LEVEL"] = "WARNING"
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from llm_ide_rules.commands.config import config_main
|
|
13
|
+
from llm_ide_rules.commands.delete import delete_main
|
|
14
|
+
from llm_ide_rules.commands.download import download_main
|
|
15
|
+
from llm_ide_rules.commands.explode import explode_main
|
|
16
|
+
from llm_ide_rules.commands.exploded import exploded_main
|
|
17
|
+
from llm_ide_rules.commands.ignores import ignores_main
|
|
18
|
+
from llm_ide_rules.commands.implode import (
|
|
19
|
+
agents,
|
|
20
|
+
antigravity,
|
|
21
|
+
claude,
|
|
22
|
+
cursor,
|
|
23
|
+
github,
|
|
24
|
+
grok,
|
|
25
|
+
opencode,
|
|
26
|
+
)
|
|
27
|
+
from llm_ide_rules.version import __version__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def version_callback(value: bool):
|
|
31
|
+
"""Callback to display the version and exit."""
|
|
32
|
+
if value:
|
|
33
|
+
print(f"llm-ide-rules version {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
name="llm_ide_rules",
|
|
39
|
+
help="CLI tool for managing LLM IDE prompts and rules",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback()
|
|
45
|
+
def main_callback(
|
|
46
|
+
verbose: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--verbose", "-v", help="Enable verbose logging (sets LOG_LEVEL=DEBUG)"
|
|
50
|
+
),
|
|
51
|
+
] = False,
|
|
52
|
+
version: Annotated[
|
|
53
|
+
bool | None,
|
|
54
|
+
typer.Option(
|
|
55
|
+
"--version",
|
|
56
|
+
help="Show the version and exit",
|
|
57
|
+
callback=version_callback,
|
|
58
|
+
is_eager=True,
|
|
59
|
+
),
|
|
60
|
+
] = None,
|
|
61
|
+
):
|
|
62
|
+
"""Global CLI options."""
|
|
63
|
+
if verbose:
|
|
64
|
+
os.environ["LOG_LEVEL"] = "DEBUG"
|
|
65
|
+
import structlog_config
|
|
66
|
+
|
|
67
|
+
structlog_config.configure_logger()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Add commands directly
|
|
71
|
+
app.command("explode", help="Convert instruction file to separate rule files")(
|
|
72
|
+
explode_main
|
|
73
|
+
)
|
|
74
|
+
app.command("exploded", help="List clients that were exploded")(exploded_main)
|
|
75
|
+
app.command("ignores", help="Generate list of files to ignore based on instructions")(
|
|
76
|
+
ignores_main
|
|
77
|
+
)
|
|
78
|
+
app.command("download", help="Download LLM instruction files from GitHub repositories")(
|
|
79
|
+
download_main
|
|
80
|
+
)
|
|
81
|
+
app.command("delete", help="Remove downloaded LLM instruction files")(delete_main)
|
|
82
|
+
app.command("config", help="Configure agents to use AGENTS.md")(config_main)
|
|
83
|
+
|
|
84
|
+
# Create implode sub-typer
|
|
85
|
+
implode_app = typer.Typer(help="Bundle rule files into a single instruction file")
|
|
86
|
+
implode_app.command(
|
|
87
|
+
"cursor", help="Bundle Cursor rules and commands into a single file"
|
|
88
|
+
)(cursor)
|
|
89
|
+
implode_app.command(
|
|
90
|
+
"github", help="Bundle GitHub/Copilot instructions and prompts into a single file"
|
|
91
|
+
)(github)
|
|
92
|
+
implode_app.command(
|
|
93
|
+
"claude", help="Bundle Claude Code rules and commands into single files"
|
|
94
|
+
)(claude)
|
|
95
|
+
implode_app.command(
|
|
96
|
+
"antigravity",
|
|
97
|
+
help="Bundle Antigravity (.agents) rules and skills into single files",
|
|
98
|
+
)(antigravity)
|
|
99
|
+
implode_app.command(
|
|
100
|
+
"grok", help="Bundle Grok (.agents) rules and skills into single files"
|
|
101
|
+
)(grok)
|
|
102
|
+
implode_app.command("opencode", help="Bundle OpenCode commands into a single file")(
|
|
103
|
+
opencode
|
|
104
|
+
)
|
|
105
|
+
implode_app.command("agents", help="Bundle AGENTS.md files into a single file")(agents)
|
|
106
|
+
app.add_typer(implode_app, name="implode")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
"""Main entry point for the CLI."""
|
|
111
|
+
app()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Agent registry for LLM IDE rules."""
|
|
2
|
+
|
|
3
|
+
from llm_ide_rules.agents.agents import AgentsAgent
|
|
4
|
+
from llm_ide_rules.agents.antigravity import AntigravityAgent
|
|
5
|
+
from llm_ide_rules.agents.base import BaseAgent
|
|
6
|
+
from llm_ide_rules.agents.claude import ClaudeAgent
|
|
7
|
+
from llm_ide_rules.agents.cursor import CursorAgent
|
|
8
|
+
from llm_ide_rules.agents.github import GitHubAgent
|
|
9
|
+
from llm_ide_rules.agents.grok import GrokAgent
|
|
10
|
+
from llm_ide_rules.agents.opencode import OpenCodeAgent
|
|
11
|
+
from llm_ide_rules.agents.vscode import VSCodeAgent
|
|
12
|
+
|
|
13
|
+
AGENTS: dict[str, type[BaseAgent]] = {
|
|
14
|
+
"cursor": CursorAgent,
|
|
15
|
+
"github": GitHubAgent,
|
|
16
|
+
"claude": ClaudeAgent,
|
|
17
|
+
"opencode": OpenCodeAgent,
|
|
18
|
+
"agents": AgentsAgent,
|
|
19
|
+
"vscode": VSCodeAgent,
|
|
20
|
+
"antigravity": AntigravityAgent,
|
|
21
|
+
"grok": GrokAgent,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Aliases for user-friendly names
|
|
25
|
+
AGENT_ALIASES: dict[str, str] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_agent(name: str) -> BaseAgent:
|
|
29
|
+
"""Get an agent instance by name."""
|
|
30
|
+
if name in AGENT_ALIASES:
|
|
31
|
+
name = AGENT_ALIASES[name]
|
|
32
|
+
if name not in AGENTS:
|
|
33
|
+
raise ValueError(f"Unknown agent: {name}. Available: {list(AGENTS.keys())}")
|
|
34
|
+
return AGENTS[name]()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_all_agents() -> list[BaseAgent]:
|
|
38
|
+
"""Get instances of all registered agents."""
|
|
39
|
+
seen: set[type[BaseAgent]] = set()
|
|
40
|
+
result: list[BaseAgent] = []
|
|
41
|
+
for agent_cls in AGENTS.values():
|
|
42
|
+
if agent_cls not in seen:
|
|
43
|
+
seen.add(agent_cls)
|
|
44
|
+
result.append(agent_cls())
|
|
45
|
+
return result
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Agents documentation agent implementation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from llm_ide_rules.agents.base import BaseAgent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentsAgent(BaseAgent):
|
|
11
|
+
"""Agent for generating AGENTS.md documentation."""
|
|
12
|
+
|
|
13
|
+
name = "agents"
|
|
14
|
+
rules_dir = None
|
|
15
|
+
commands_dir = None
|
|
16
|
+
rule_extension = None
|
|
17
|
+
command_extension = None
|
|
18
|
+
|
|
19
|
+
def bundle_rules(
|
|
20
|
+
self,
|
|
21
|
+
output_file: Path,
|
|
22
|
+
section_globs: dict[str, str | None] | None = None,
|
|
23
|
+
filename: str = "AGENTS.md",
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Bundle all AGENTS.md files into a single output file."""
|
|
26
|
+
base_dir = output_file.parent
|
|
27
|
+
# Find all AGENTS.md files recursively
|
|
28
|
+
agents_files = list(base_dir.rglob(filename))
|
|
29
|
+
if not agents_files:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
# Sort files: root first, then by depth and name
|
|
33
|
+
root_agents = [f for f in agents_files if f.parent == base_dir]
|
|
34
|
+
subdir_agents = sorted(
|
|
35
|
+
[f for f in agents_files if f.parent != base_dir],
|
|
36
|
+
key=lambda f: f.as_posix(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
all_agents = root_agents + subdir_agents
|
|
40
|
+
content_parts: list[str] = []
|
|
41
|
+
processed_sections: set[str] = set()
|
|
42
|
+
|
|
43
|
+
for agents_file in all_agents:
|
|
44
|
+
content = agents_file.read_text().strip()
|
|
45
|
+
if not content:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
from llm_ide_rules.markdown_parser import parse_sections
|
|
49
|
+
|
|
50
|
+
general, sections = parse_sections(content)
|
|
51
|
+
|
|
52
|
+
# Only include general instructions once (from root)
|
|
53
|
+
if general and agents_file.parent == base_dir:
|
|
54
|
+
from llm_ide_rules.agents.base import trim_content
|
|
55
|
+
|
|
56
|
+
trimmed = trim_content(general)
|
|
57
|
+
if trimmed:
|
|
58
|
+
content_parts.extend(trimmed)
|
|
59
|
+
content_parts.append("\n\n")
|
|
60
|
+
|
|
61
|
+
for section_name, section_data in sections.items():
|
|
62
|
+
if section_name in processed_sections:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
processed_sections.add(section_name)
|
|
66
|
+
section_content = section_data.content
|
|
67
|
+
|
|
68
|
+
# Reconstruct header and globs if it's from a subdirectory
|
|
69
|
+
content_parts.append(f"## {section_name}\n\n")
|
|
70
|
+
|
|
71
|
+
if agents_file.parent != base_dir:
|
|
72
|
+
rel_dir = agents_file.parent.relative_to(base_dir)
|
|
73
|
+
content_parts.append(f"globs: {rel_dir.as_posix()}/**/*\n\n")
|
|
74
|
+
|
|
75
|
+
from llm_ide_rules.agents.base import trim_content
|
|
76
|
+
|
|
77
|
+
trimmed = trim_content(section_content)
|
|
78
|
+
# Remove the section header from content as we already added it
|
|
79
|
+
if trimmed and trimmed[0].startswith("## "):
|
|
80
|
+
trimmed = trimmed[1:]
|
|
81
|
+
trimmed = trim_content(trimmed)
|
|
82
|
+
|
|
83
|
+
if trimmed:
|
|
84
|
+
content_parts.extend(trimmed)
|
|
85
|
+
content_parts.append("\n\n")
|
|
86
|
+
|
|
87
|
+
if not content_parts:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
self._write_bundled_content(output_file, "".join(content_parts))
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def bundle_commands(
|
|
94
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Agents doesn't support bundling commands."""
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def write_rule(
|
|
100
|
+
self,
|
|
101
|
+
content_lines: list[str],
|
|
102
|
+
filename: str,
|
|
103
|
+
rules_dir: Path,
|
|
104
|
+
glob_pattern: str | None = None,
|
|
105
|
+
description: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Agents doesn't support writing rules."""
|
|
108
|
+
|
|
109
|
+
def write_command(
|
|
110
|
+
self,
|
|
111
|
+
content_lines: list[str],
|
|
112
|
+
filename: str,
|
|
113
|
+
commands_dir: Path,
|
|
114
|
+
section_name: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Agents doesn't support writing commands."""
|
|
117
|
+
|
|
118
|
+
def generate_root_doc(
|
|
119
|
+
self,
|
|
120
|
+
general_lines: list[str],
|
|
121
|
+
rules_sections: dict[str, list[str]],
|
|
122
|
+
command_sections: dict[str, list[str]],
|
|
123
|
+
output_dir: Path,
|
|
124
|
+
section_globs: dict[str, str | None] | None = None,
|
|
125
|
+
filename: str = "AGENTS.md",
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Generate AGENTS.md files, potentially distributed based on globs."""
|
|
128
|
+
if not section_globs:
|
|
129
|
+
# Fallback to single root AGENTS.md
|
|
130
|
+
content = self.build_root_doc_content(general_lines, rules_sections)
|
|
131
|
+
if content.strip():
|
|
132
|
+
(output_dir / filename).write_text(content)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Group rules by target directory
|
|
136
|
+
rules_by_dir: dict[Path, dict[str, list[str]]] = {}
|
|
137
|
+
|
|
138
|
+
# Always include root directory for rules without specific directory targets
|
|
139
|
+
rules_by_dir[output_dir] = {}
|
|
140
|
+
|
|
141
|
+
from llm_ide_rules.utils import resolve_target_dir
|
|
142
|
+
|
|
143
|
+
for section_name, lines in rules_sections.items():
|
|
144
|
+
glob_pattern = section_globs.get(section_name)
|
|
145
|
+
target_dir = resolve_target_dir(output_dir, glob_pattern)
|
|
146
|
+
|
|
147
|
+
if target_dir != output_dir and glob_pattern and "**" in glob_pattern:
|
|
148
|
+
prefix = glob_pattern.split("**")[0].strip("/")
|
|
149
|
+
potential_dir = output_dir / prefix
|
|
150
|
+
if target_dir != potential_dir:
|
|
151
|
+
rel_potential = potential_dir.relative_to(output_dir)
|
|
152
|
+
rel_actual = target_dir.relative_to(output_dir)
|
|
153
|
+
typer.secho(
|
|
154
|
+
f"Warning: Directory '{rel_potential}' for section '{section_name}' does not exist. "
|
|
155
|
+
f"Placing in '{rel_actual}' instead.",
|
|
156
|
+
fg=typer.colors.YELLOW,
|
|
157
|
+
err=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if target_dir not in rules_by_dir:
|
|
161
|
+
rules_by_dir[target_dir] = {}
|
|
162
|
+
|
|
163
|
+
rules_by_dir[target_dir][section_name] = lines
|
|
164
|
+
|
|
165
|
+
# Generate AGENTS.md for each directory
|
|
166
|
+
for target_dir, sections in rules_by_dir.items():
|
|
167
|
+
if not sections:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
# Only include general instructions in the root AGENTS.md
|
|
171
|
+
current_general_lines = general_lines if target_dir == output_dir else []
|
|
172
|
+
|
|
173
|
+
content = self.build_root_doc_content(current_general_lines, sections)
|
|
174
|
+
if content.strip():
|
|
175
|
+
(target_dir / filename).write_text(content)
|
|
176
|
+
|
|
177
|
+
def detect(self, base_dir: Path) -> bool:
|
|
178
|
+
"detect if AGENTS.md is in use in the given directory"
|
|
179
|
+
return (base_dir / "AGENTS.md").exists()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Antigravity CLI agent implementation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from llm_ide_rules.agents.dotagents import DotAgentsBaseAgent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AntigravityAgent(DotAgentsBaseAgent):
|
|
9
|
+
"""Agent for Antigravity CLI."""
|
|
10
|
+
|
|
11
|
+
name = "antigravity"
|
|
12
|
+
|
|
13
|
+
def detect(self, base_dir: Path) -> bool:
|
|
14
|
+
"detect if Antigravity is in use in the given directory"
|
|
15
|
+
return (
|
|
16
|
+
super().detect(base_dir)
|
|
17
|
+
or (base_dir / ".gemini").exists()
|
|
18
|
+
or (base_dir / ".antigravity").exists()
|
|
19
|
+
)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Base agent class and shared utilities for LLM IDE rules."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from llm_ide_rules.constants import header_to_filename
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseAgent(ABC):
|
|
10
|
+
"""Base class for all IDE agents."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
rules_dir: str | None = None
|
|
14
|
+
commands_dir: str | None = None
|
|
15
|
+
rule_extension: str | None = None
|
|
16
|
+
command_extension: str | None = None
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def bundle_rules(
|
|
20
|
+
self,
|
|
21
|
+
output_file: Path,
|
|
22
|
+
section_globs: dict[str, str | None] | None = None,
|
|
23
|
+
filename: str = "AGENTS.md",
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Bundle rule files into a single output file."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def bundle_commands(
|
|
30
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""Bundle command files into a single output file."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def write_rule(
|
|
37
|
+
self,
|
|
38
|
+
content_lines: list[str],
|
|
39
|
+
filename: str,
|
|
40
|
+
rules_dir: Path,
|
|
41
|
+
glob_pattern: str | None = None,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Write a single rule file."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def write_command(
|
|
49
|
+
self,
|
|
50
|
+
content_lines: list[str],
|
|
51
|
+
filename: str,
|
|
52
|
+
commands_dir: Path,
|
|
53
|
+
section_name: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Write a single command file."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def detect(self, base_dir: Path) -> bool:
|
|
59
|
+
"detect if this agent is in use in the given directory"
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def configure_agents_md(self, base_dir: Path) -> bool:
|
|
63
|
+
"""Configure the agent to use AGENTS.md as context (default: no-op).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
bool: True if configuration was applied, False otherwise.
|
|
67
|
+
"""
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def generate_root_doc(
|
|
71
|
+
self,
|
|
72
|
+
general_lines: list[str],
|
|
73
|
+
rules_sections: dict[str, list[str]],
|
|
74
|
+
command_sections: dict[str, list[str]],
|
|
75
|
+
output_dir: Path,
|
|
76
|
+
section_globs: dict[str, str | None] | None = None,
|
|
77
|
+
filename: str = "AGENTS.md",
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Generate a root documentation file (e.g. CLAUDE.md) if supported."""
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
def build_root_doc_content(
|
|
83
|
+
self,
|
|
84
|
+
general_lines: list[str],
|
|
85
|
+
rules_sections: dict[str, list[str]],
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Build the content string for a root documentation file by aggregating rules."""
|
|
88
|
+
content = []
|
|
89
|
+
|
|
90
|
+
# Add general instructions
|
|
91
|
+
if general_lines:
|
|
92
|
+
trimmed = trim_content(general_lines)
|
|
93
|
+
if trimmed:
|
|
94
|
+
content.extend(trimmed)
|
|
95
|
+
content.append("\n\n")
|
|
96
|
+
|
|
97
|
+
# Add sections in document order (dict maintains insertion order in Python 3.7+)
|
|
98
|
+
for lines in rules_sections.values():
|
|
99
|
+
trimmed = trim_content(lines)
|
|
100
|
+
if trimmed:
|
|
101
|
+
content.extend(trimmed)
|
|
102
|
+
content.append("\n\n")
|
|
103
|
+
|
|
104
|
+
return "".join(content).strip() + "\n" if content else ""
|
|
105
|
+
|
|
106
|
+
def get_rules_path(self, base_dir: Path) -> Path:
|
|
107
|
+
"""Get the full path to the rules directory."""
|
|
108
|
+
if not self.rules_dir:
|
|
109
|
+
raise NotImplementedError(f"{self.name} does not support rules")
|
|
110
|
+
return base_dir / self.rules_dir
|
|
111
|
+
|
|
112
|
+
def get_commands_path(self, base_dir: Path) -> Path:
|
|
113
|
+
"""Get the full path to the commands directory."""
|
|
114
|
+
if not self.commands_dir:
|
|
115
|
+
raise NotImplementedError(f"{self.name} does not support commands")
|
|
116
|
+
return base_dir / self.commands_dir
|
|
117
|
+
|
|
118
|
+
def _write_bundled_content(self, output_file: Path, content: str) -> None:
|
|
119
|
+
"""Write bundled content to output file, preserving custom instructions after marker."""
|
|
120
|
+
marker = "<!-- END CLONED INSTRUCTIONS -->"
|
|
121
|
+
local_custom_content = ""
|
|
122
|
+
|
|
123
|
+
if output_file.exists():
|
|
124
|
+
try:
|
|
125
|
+
current_content = output_file.read_text(encoding="utf-8")
|
|
126
|
+
if marker in current_content:
|
|
127
|
+
local_custom_content = current_content.split(marker, 1)[1]
|
|
128
|
+
except OSError:
|
|
129
|
+
# Fallback if file cannot be read
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
if marker not in content:
|
|
133
|
+
# Ensure marker is present at the end of the bundled content
|
|
134
|
+
content = content.rstrip() + f"\n\n{marker}\n"
|
|
135
|
+
|
|
136
|
+
output_file.write_text(content + local_custom_content, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def strip_yaml_frontmatter(text: str) -> str:
|
|
140
|
+
"""Strip YAML frontmatter from text."""
|
|
141
|
+
lines = text.splitlines()
|
|
142
|
+
if lines and lines[0].strip() == "---":
|
|
143
|
+
for i in range(1, len(lines)):
|
|
144
|
+
if lines[i].strip() == "---":
|
|
145
|
+
return "\n".join(lines[i + 1 :]).lstrip("\n")
|
|
146
|
+
return text
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def strip_header(text: str) -> str:
|
|
150
|
+
"""Remove the first markdown header (## Header) from text if present."""
|
|
151
|
+
lines = text.splitlines()
|
|
152
|
+
if lines and lines[0].startswith("## "):
|
|
153
|
+
remaining_lines = lines[1:]
|
|
154
|
+
while remaining_lines and not remaining_lines[0].strip():
|
|
155
|
+
remaining_lines = remaining_lines[1:]
|
|
156
|
+
return "\n".join(remaining_lines)
|
|
157
|
+
return text
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_ordered_files(
|
|
161
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
162
|
+
) -> list[Path]:
|
|
163
|
+
"""Order files based on section_globs key order, with unmapped files at the end.
|
|
164
|
+
|
|
165
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
166
|
+
"""
|
|
167
|
+
if not section_globs_keys:
|
|
168
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
169
|
+
|
|
170
|
+
file_dict = {f.stem: f for f in file_list}
|
|
171
|
+
ordered_files = []
|
|
172
|
+
|
|
173
|
+
for section_name in section_globs_keys:
|
|
174
|
+
filename = header_to_filename(section_name)
|
|
175
|
+
if filename in file_dict:
|
|
176
|
+
ordered_files.append(file_dict[filename])
|
|
177
|
+
del file_dict[filename]
|
|
178
|
+
|
|
179
|
+
remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
|
|
180
|
+
ordered_files.extend(remaining_files)
|
|
181
|
+
|
|
182
|
+
return ordered_files
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_ordered_files_github(
|
|
186
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
187
|
+
) -> list[Path]:
|
|
188
|
+
"""Order GitHub instruction files, handling .instructions suffix.
|
|
189
|
+
|
|
190
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
191
|
+
"""
|
|
192
|
+
if not section_globs_keys:
|
|
193
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
194
|
+
|
|
195
|
+
file_dict = {}
|
|
196
|
+
for f in file_list:
|
|
197
|
+
base_stem = f.stem.replace(".instructions", "")
|
|
198
|
+
file_dict[base_stem] = f
|
|
199
|
+
|
|
200
|
+
ordered_files = []
|
|
201
|
+
|
|
202
|
+
for section_name in section_globs_keys:
|
|
203
|
+
filename = header_to_filename(section_name)
|
|
204
|
+
if filename in file_dict:
|
|
205
|
+
ordered_files.append(file_dict[filename])
|
|
206
|
+
del file_dict[filename]
|
|
207
|
+
|
|
208
|
+
remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
|
|
209
|
+
ordered_files.extend(remaining_files)
|
|
210
|
+
|
|
211
|
+
return ordered_files
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def resolve_header_from_stem(stem: str, section_globs: dict[str, str | None]) -> str:
|
|
215
|
+
"""Return the canonical header for a given filename stem.
|
|
216
|
+
|
|
217
|
+
Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
|
|
218
|
+
Fallback to title-casing the filename when not found in section_globs.
|
|
219
|
+
"""
|
|
220
|
+
for section_name in section_globs:
|
|
221
|
+
if header_to_filename(section_name) == stem:
|
|
222
|
+
return section_name
|
|
223
|
+
|
|
224
|
+
return stem.replace("-", " ").title()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def trim_content(content_lines: list[str]) -> list[str]:
|
|
228
|
+
"""Remove leading and trailing empty lines from content."""
|
|
229
|
+
start = 0
|
|
230
|
+
for i, line in enumerate(content_lines):
|
|
231
|
+
if line.strip():
|
|
232
|
+
start = i
|
|
233
|
+
break
|
|
234
|
+
else:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
end = len(content_lines)
|
|
238
|
+
for i in range(len(content_lines) - 1, -1, -1):
|
|
239
|
+
if content_lines[i].strip():
|
|
240
|
+
end = i + 1
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
return content_lines[start:end]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def write_rule_file(path: Path, header_yaml: str, content_lines: list[str]) -> None:
|
|
247
|
+
"""Write a rule file with front matter and content."""
|
|
248
|
+
trimmed_content = trim_content(content_lines)
|
|
249
|
+
output = header_yaml.strip() + "\n" + "".join(trimmed_content)
|
|
250
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
path.write_text(output)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def replace_header_with_proper_casing(
|
|
255
|
+
content_lines: list[str], proper_header: str
|
|
256
|
+
) -> list[str]:
|
|
257
|
+
"""Replace the first header in content with the properly cased version."""
|
|
258
|
+
if not content_lines:
|
|
259
|
+
return content_lines
|
|
260
|
+
|
|
261
|
+
for i, line in enumerate(content_lines):
|
|
262
|
+
if line.startswith("## "):
|
|
263
|
+
content_lines[i] = f"## {proper_header}\n"
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
return content_lines
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def extract_description_and_filter_content(
|
|
270
|
+
content_lines: list[str], default_description: str
|
|
271
|
+
) -> tuple[str, list[str]]:
|
|
272
|
+
"""Extract description from first non-empty line that starts with 'Description:' and return filtered content."""
|
|
273
|
+
trimmed_content = trim_content(content_lines)
|
|
274
|
+
description = ""
|
|
275
|
+
description_line = None
|
|
276
|
+
|
|
277
|
+
for i, line in enumerate(trimmed_content):
|
|
278
|
+
stripped_line = line.strip()
|
|
279
|
+
if (
|
|
280
|
+
stripped_line
|
|
281
|
+
and not stripped_line.startswith("#")
|
|
282
|
+
and not stripped_line.startswith("##")
|
|
283
|
+
):
|
|
284
|
+
if stripped_line.startswith("Description:"):
|
|
285
|
+
description = stripped_line[len("Description:") :].strip()
|
|
286
|
+
description_line = i
|
|
287
|
+
break
|
|
288
|
+
else:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if description and description_line is not None:
|
|
292
|
+
filtered_content = (
|
|
293
|
+
trimmed_content[:description_line] + trimmed_content[description_line + 1 :]
|
|
294
|
+
)
|
|
295
|
+
filtered_content = trim_content(filtered_content)
|
|
296
|
+
else:
|
|
297
|
+
filtered_content = trimmed_content
|
|
298
|
+
|
|
299
|
+
return description, filtered_content
|