llm-ide-rules 0.5.0__py3-none-any.whl → 0.7.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 +53 -9
- llm_ide_rules/__main__.py +1 -1
- llm_ide_rules/agents/__init__.py +28 -0
- llm_ide_rules/agents/base.py +329 -0
- llm_ide_rules/agents/claude.py +108 -0
- llm_ide_rules/agents/cursor.py +199 -0
- llm_ide_rules/agents/gemini.py +177 -0
- llm_ide_rules/agents/github.py +212 -0
- llm_ide_rules/agents/opencode.py +130 -0
- llm_ide_rules/commands/delete.py +24 -34
- llm_ide_rules/commands/download.py +146 -60
- llm_ide_rules/commands/explode.py +222 -382
- llm_ide_rules/commands/implode.py +174 -360
- llm_ide_rules/commands/mcp.py +119 -0
- llm_ide_rules/constants.py +6 -29
- llm_ide_rules/log.py +9 -0
- llm_ide_rules/markdown_parser.py +108 -0
- llm_ide_rules/mcp/__init__.py +7 -0
- llm_ide_rules/mcp/models.py +21 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/METADATA +36 -59
- llm_ide_rules-0.7.0.dist-info/RECORD +23 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/WHEEL +2 -2
- llm_ide_rules/sections.json +0 -27
- llm_ide_rules-0.5.0.dist-info/RECORD +0 -12
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/entry_points.txt +0 -0
llm_ide_rules/__init__.py
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"""LLM Rules CLI package for managing IDE prompts and rules."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
if "LOG_LEVEL" not in os.environ:
|
|
6
|
+
os.environ["LOG_LEVEL"] = "WARNING"
|
|
7
|
+
|
|
3
8
|
import typer
|
|
4
9
|
from typing_extensions import Annotated
|
|
5
10
|
|
|
6
11
|
from llm_ide_rules.commands.explode import explode_main
|
|
7
|
-
from llm_ide_rules.commands.implode import cursor, github, claude, gemini
|
|
12
|
+
from llm_ide_rules.commands.implode import cursor, github, claude, gemini, opencode
|
|
8
13
|
from llm_ide_rules.commands.download import download_main
|
|
9
14
|
from llm_ide_rules.commands.delete import delete_main
|
|
15
|
+
from llm_ide_rules.commands.mcp import mcp_app
|
|
10
16
|
|
|
11
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.7.0"
|
|
12
18
|
|
|
13
19
|
app = typer.Typer(
|
|
14
20
|
name="llm_ide_rules",
|
|
@@ -16,22 +22,60 @@ app = typer.Typer(
|
|
|
16
22
|
no_args_is_help=True,
|
|
17
23
|
)
|
|
18
24
|
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main_callback(
|
|
28
|
+
verbose: Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
typer.Option(
|
|
31
|
+
"--verbose", "-v", help="Enable verbose logging (sets LOG_LEVEL=DEBUG)"
|
|
32
|
+
),
|
|
33
|
+
] = False,
|
|
34
|
+
):
|
|
35
|
+
"""Global CLI options."""
|
|
36
|
+
if verbose:
|
|
37
|
+
os.environ["LOG_LEVEL"] = "DEBUG"
|
|
38
|
+
import structlog_config
|
|
39
|
+
|
|
40
|
+
structlog_config.configure_logger()
|
|
41
|
+
|
|
42
|
+
|
|
19
43
|
# Add commands directly
|
|
20
|
-
app.command("explode", help="Convert instruction file to separate rule files")(
|
|
21
|
-
|
|
44
|
+
app.command("explode", help="Convert instruction file to separate rule files")(
|
|
45
|
+
explode_main
|
|
46
|
+
)
|
|
47
|
+
app.command("download", help="Download LLM instruction files from GitHub repositories")(
|
|
48
|
+
download_main
|
|
49
|
+
)
|
|
22
50
|
app.command("delete", help="Remove downloaded LLM instruction files")(delete_main)
|
|
23
51
|
|
|
24
52
|
# Create implode sub-typer
|
|
25
53
|
implode_app = typer.Typer(help="Bundle rule files into a single instruction file")
|
|
26
|
-
implode_app.command(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
implode_app.command(
|
|
54
|
+
implode_app.command(
|
|
55
|
+
"cursor", help="Bundle Cursor rules and commands into a single file"
|
|
56
|
+
)(cursor)
|
|
57
|
+
implode_app.command(
|
|
58
|
+
"github", help="Bundle GitHub/Copilot instructions and prompts into a single file"
|
|
59
|
+
)(github)
|
|
60
|
+
implode_app.command("claude", help="Bundle Claude Code commands into a single file")(
|
|
61
|
+
claude
|
|
62
|
+
)
|
|
63
|
+
implode_app.command("gemini", help="Bundle Gemini CLI commands into a single file")(
|
|
64
|
+
gemini
|
|
65
|
+
)
|
|
66
|
+
implode_app.command("opencode", help="Bundle OpenCode commands into a single file")(
|
|
67
|
+
opencode
|
|
68
|
+
)
|
|
30
69
|
app.add_typer(implode_app, name="implode")
|
|
31
70
|
|
|
71
|
+
# Add MCP configuration management
|
|
72
|
+
app.add_typer(mcp_app, name="mcp")
|
|
73
|
+
|
|
74
|
+
|
|
32
75
|
def main():
|
|
33
76
|
"""Main entry point for the CLI."""
|
|
34
77
|
app()
|
|
35
78
|
|
|
79
|
+
|
|
36
80
|
if __name__ == "__main__":
|
|
37
|
-
main()
|
|
81
|
+
main()
|
llm_ide_rules/__main__.py
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Agent registry for LLM IDE rules."""
|
|
2
|
+
|
|
3
|
+
from llm_ide_rules.agents.base import BaseAgent
|
|
4
|
+
from llm_ide_rules.agents.cursor import CursorAgent
|
|
5
|
+
from llm_ide_rules.agents.github import GitHubAgent
|
|
6
|
+
from llm_ide_rules.agents.claude import ClaudeAgent
|
|
7
|
+
from llm_ide_rules.agents.gemini import GeminiAgent
|
|
8
|
+
from llm_ide_rules.agents.opencode import OpenCodeAgent
|
|
9
|
+
|
|
10
|
+
AGENTS: dict[str, type[BaseAgent]] = {
|
|
11
|
+
"cursor": CursorAgent,
|
|
12
|
+
"github": GitHubAgent,
|
|
13
|
+
"claude": ClaudeAgent,
|
|
14
|
+
"gemini": GeminiAgent,
|
|
15
|
+
"opencode": OpenCodeAgent,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_agent(name: str) -> BaseAgent:
|
|
20
|
+
"""Get an agent instance by name."""
|
|
21
|
+
if name not in AGENTS:
|
|
22
|
+
raise ValueError(f"Unknown agent: {name}. Available: {list(AGENTS.keys())}")
|
|
23
|
+
return AGENTS[name]()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_all_agents() -> list[BaseAgent]:
|
|
27
|
+
"""Get instances of all registered agents."""
|
|
28
|
+
return [agent_cls() for agent_cls in AGENTS.values()]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Base agent class and shared utilities for LLM IDE rules."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from llm_ide_rules.constants import header_to_filename
|
|
8
|
+
from llm_ide_rules.mcp import McpServer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseAgent(ABC):
|
|
12
|
+
"""Base class for all IDE agents."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
rules_dir: str | None = None
|
|
16
|
+
commands_dir: str | None = None
|
|
17
|
+
rule_extension: str | None = None
|
|
18
|
+
command_extension: str | None = None
|
|
19
|
+
|
|
20
|
+
mcp_global_path: str | None = None
|
|
21
|
+
mcp_project_path: str | None = None
|
|
22
|
+
mcp_root_key: str = "mcpServers"
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def bundle_rules(
|
|
26
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Bundle rule files into a single output file."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def bundle_commands(
|
|
33
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
34
|
+
) -> bool:
|
|
35
|
+
"""Bundle command files into a single output file."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def write_rule(
|
|
40
|
+
self,
|
|
41
|
+
content_lines: list[str],
|
|
42
|
+
filename: str,
|
|
43
|
+
rules_dir: Path,
|
|
44
|
+
glob_pattern: str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Write a single rule file."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def write_command(
|
|
51
|
+
self,
|
|
52
|
+
content_lines: list[str],
|
|
53
|
+
filename: str,
|
|
54
|
+
commands_dir: Path,
|
|
55
|
+
section_name: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Write a single command file."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def generate_root_doc(
|
|
61
|
+
self,
|
|
62
|
+
general_lines: list[str],
|
|
63
|
+
rules_sections: dict[str, list[str]],
|
|
64
|
+
command_sections: dict[str, list[str]],
|
|
65
|
+
output_dir: Path,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Generate a root documentation file (e.g. CLAUDE.md) if supported."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def build_root_doc_content(
|
|
71
|
+
self,
|
|
72
|
+
general_lines: list[str],
|
|
73
|
+
rules_sections: dict[str, list[str]],
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Build the content string for a root documentation file by aggregating rules."""
|
|
76
|
+
content = []
|
|
77
|
+
|
|
78
|
+
# Add general instructions
|
|
79
|
+
if general_lines:
|
|
80
|
+
trimmed = trim_content(general_lines)
|
|
81
|
+
if trimmed:
|
|
82
|
+
content.extend(trimmed)
|
|
83
|
+
content.append("\n\n")
|
|
84
|
+
|
|
85
|
+
# Add sections in document order (dict maintains insertion order in Python 3.7+)
|
|
86
|
+
for section_name, lines in rules_sections.items():
|
|
87
|
+
trimmed = trim_content(lines)
|
|
88
|
+
if trimmed:
|
|
89
|
+
content.extend(trimmed)
|
|
90
|
+
content.append("\n\n")
|
|
91
|
+
|
|
92
|
+
return "".join(content).strip() + "\n" if content else ""
|
|
93
|
+
|
|
94
|
+
def get_rules_path(self, base_dir: Path) -> Path:
|
|
95
|
+
"""Get the full path to the rules directory."""
|
|
96
|
+
if not self.rules_dir:
|
|
97
|
+
raise NotImplementedError(f"{self.name} does not support rules")
|
|
98
|
+
return base_dir / self.rules_dir
|
|
99
|
+
|
|
100
|
+
def get_commands_path(self, base_dir: Path) -> Path:
|
|
101
|
+
"""Get the full path to the commands directory."""
|
|
102
|
+
if not self.commands_dir:
|
|
103
|
+
raise NotImplementedError(f"{self.name} does not support commands")
|
|
104
|
+
return base_dir / self.commands_dir
|
|
105
|
+
|
|
106
|
+
def transform_mcp_server(self, server: McpServer) -> dict:
|
|
107
|
+
"""Transform unified server to platform-specific format."""
|
|
108
|
+
if server.url:
|
|
109
|
+
result: dict = {"url": server.url}
|
|
110
|
+
if server.env:
|
|
111
|
+
result["env"] = server.env
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
result: dict = {"command": server.command, "args": server.args or []}
|
|
115
|
+
if server.env:
|
|
116
|
+
result["env"] = server.env
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
|
|
120
|
+
"""Transform platform config back to unified format."""
|
|
121
|
+
if "url" in config:
|
|
122
|
+
return McpServer(
|
|
123
|
+
url=config["url"],
|
|
124
|
+
env=config.get("env"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return McpServer(
|
|
128
|
+
command=config["command"],
|
|
129
|
+
args=config.get("args", []),
|
|
130
|
+
env=config.get("env"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def write_mcp_config(self, servers: dict, path: Path) -> None:
|
|
134
|
+
"""Write MCP config to path."""
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
config = {self.mcp_root_key: servers}
|
|
137
|
+
path.write_text(json.dumps(config, indent=2))
|
|
138
|
+
|
|
139
|
+
def read_mcp_config(self, path: Path) -> dict | None:
|
|
140
|
+
"""Read MCP config from path."""
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
config = json.loads(path.read_text())
|
|
145
|
+
return config.get(self.mcp_root_key)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def strip_yaml_frontmatter(text: str) -> str:
|
|
149
|
+
"""Strip YAML frontmatter from text."""
|
|
150
|
+
lines = text.splitlines()
|
|
151
|
+
if lines and lines[0].strip() == "---":
|
|
152
|
+
for i in range(1, len(lines)):
|
|
153
|
+
if lines[i].strip() == "---":
|
|
154
|
+
return "\n".join(lines[i + 1 :]).lstrip("\n")
|
|
155
|
+
return text
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def strip_header(text: str) -> str:
|
|
159
|
+
"""Remove the first markdown header (## Header) from text if present."""
|
|
160
|
+
lines = text.splitlines()
|
|
161
|
+
if lines and lines[0].startswith("## "):
|
|
162
|
+
remaining_lines = lines[1:]
|
|
163
|
+
while remaining_lines and not remaining_lines[0].strip():
|
|
164
|
+
remaining_lines = remaining_lines[1:]
|
|
165
|
+
return "\n".join(remaining_lines)
|
|
166
|
+
return text
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def strip_toml_metadata(text: str) -> str:
|
|
170
|
+
"""Extract content from TOML prompt block (supports old [command] shell=... and new prompt=...)."""
|
|
171
|
+
import tomllib
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
data = tomllib.loads(text)
|
|
175
|
+
# Check new format
|
|
176
|
+
if "prompt" in data:
|
|
177
|
+
return str(data["prompt"]).strip()
|
|
178
|
+
# Check legacy format
|
|
179
|
+
if (
|
|
180
|
+
"command" in data
|
|
181
|
+
and isinstance(data["command"], dict)
|
|
182
|
+
and "shell" in data["command"]
|
|
183
|
+
):
|
|
184
|
+
return str(data["command"]["shell"]).strip()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return text.strip()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_ordered_files(
|
|
192
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
193
|
+
) -> list[Path]:
|
|
194
|
+
"""Order files based on section_globs key order, with unmapped files at the end.
|
|
195
|
+
|
|
196
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
197
|
+
"""
|
|
198
|
+
if not section_globs_keys:
|
|
199
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
200
|
+
|
|
201
|
+
file_dict = {f.stem: f for f in file_list}
|
|
202
|
+
ordered_files = []
|
|
203
|
+
|
|
204
|
+
for section_name in section_globs_keys:
|
|
205
|
+
filename = header_to_filename(section_name)
|
|
206
|
+
if filename in file_dict:
|
|
207
|
+
ordered_files.append(file_dict[filename])
|
|
208
|
+
del file_dict[filename]
|
|
209
|
+
|
|
210
|
+
remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
|
|
211
|
+
ordered_files.extend(remaining_files)
|
|
212
|
+
|
|
213
|
+
return ordered_files
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_ordered_files_github(
|
|
217
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
218
|
+
) -> list[Path]:
|
|
219
|
+
"""Order GitHub instruction files, handling .instructions suffix.
|
|
220
|
+
|
|
221
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
222
|
+
"""
|
|
223
|
+
if not section_globs_keys:
|
|
224
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
225
|
+
|
|
226
|
+
file_dict = {}
|
|
227
|
+
for f in file_list:
|
|
228
|
+
base_stem = f.stem.replace(".instructions", "")
|
|
229
|
+
file_dict[base_stem] = f
|
|
230
|
+
|
|
231
|
+
ordered_files = []
|
|
232
|
+
|
|
233
|
+
for section_name in section_globs_keys:
|
|
234
|
+
filename = header_to_filename(section_name)
|
|
235
|
+
if filename in file_dict:
|
|
236
|
+
ordered_files.append(file_dict[filename])
|
|
237
|
+
del file_dict[filename]
|
|
238
|
+
|
|
239
|
+
remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
|
|
240
|
+
ordered_files.extend(remaining_files)
|
|
241
|
+
|
|
242
|
+
return ordered_files
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_header_from_stem(stem: str, section_globs: dict[str, str | None]) -> str:
|
|
246
|
+
"""Return the canonical header for a given filename stem.
|
|
247
|
+
|
|
248
|
+
Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
|
|
249
|
+
Fallback to title-casing the filename when not found in section_globs.
|
|
250
|
+
"""
|
|
251
|
+
for section_name in section_globs.keys():
|
|
252
|
+
if header_to_filename(section_name) == stem:
|
|
253
|
+
return section_name
|
|
254
|
+
|
|
255
|
+
return stem.replace("-", " ").title()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def trim_content(content_lines: list[str]) -> list[str]:
|
|
259
|
+
"""Remove leading and trailing empty lines from content."""
|
|
260
|
+
start = 0
|
|
261
|
+
for i, line in enumerate(content_lines):
|
|
262
|
+
if line.strip():
|
|
263
|
+
start = i
|
|
264
|
+
break
|
|
265
|
+
else:
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
end = len(content_lines)
|
|
269
|
+
for i in range(len(content_lines) - 1, -1, -1):
|
|
270
|
+
if content_lines[i].strip():
|
|
271
|
+
end = i + 1
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
return content_lines[start:end]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def write_rule_file(path: Path, header_yaml: str, content_lines: list[str]) -> None:
|
|
278
|
+
"""Write a rule file with front matter and content."""
|
|
279
|
+
trimmed_content = trim_content(content_lines)
|
|
280
|
+
output = header_yaml.strip() + "\n" + "".join(trimmed_content)
|
|
281
|
+
path.write_text(output)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def replace_header_with_proper_casing(
|
|
285
|
+
content_lines: list[str], proper_header: str
|
|
286
|
+
) -> list[str]:
|
|
287
|
+
"""Replace the first header in content with the properly cased version."""
|
|
288
|
+
if not content_lines:
|
|
289
|
+
return content_lines
|
|
290
|
+
|
|
291
|
+
for i, line in enumerate(content_lines):
|
|
292
|
+
if line.startswith("## "):
|
|
293
|
+
content_lines[i] = f"## {proper_header}\n"
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
return content_lines
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def extract_description_and_filter_content(
|
|
300
|
+
content_lines: list[str], default_description: str
|
|
301
|
+
) -> tuple[str, list[str]]:
|
|
302
|
+
"""Extract description from first non-empty line that starts with 'Description:' and return filtered content."""
|
|
303
|
+
trimmed_content = trim_content(content_lines)
|
|
304
|
+
description = ""
|
|
305
|
+
description_line = None
|
|
306
|
+
|
|
307
|
+
for i, line in enumerate(trimmed_content):
|
|
308
|
+
stripped_line = line.strip()
|
|
309
|
+
if (
|
|
310
|
+
stripped_line
|
|
311
|
+
and not stripped_line.startswith("#")
|
|
312
|
+
and not stripped_line.startswith("##")
|
|
313
|
+
):
|
|
314
|
+
if stripped_line.startswith("Description:"):
|
|
315
|
+
description = stripped_line[len("Description:") :].strip()
|
|
316
|
+
description_line = i
|
|
317
|
+
break
|
|
318
|
+
else:
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
if description and description_line is not None:
|
|
322
|
+
filtered_content = (
|
|
323
|
+
trimmed_content[:description_line] + trimmed_content[description_line + 1 :]
|
|
324
|
+
)
|
|
325
|
+
filtered_content = trim_content(filtered_content)
|
|
326
|
+
else:
|
|
327
|
+
filtered_content = trimmed_content
|
|
328
|
+
|
|
329
|
+
return description, filtered_content
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Claude Code agent implementation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from llm_ide_rules.agents.base import (
|
|
6
|
+
BaseAgent,
|
|
7
|
+
get_ordered_files,
|
|
8
|
+
resolve_header_from_stem,
|
|
9
|
+
trim_content,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeAgent(BaseAgent):
|
|
14
|
+
"""Agent for Claude Code."""
|
|
15
|
+
|
|
16
|
+
name = "claude"
|
|
17
|
+
rules_dir = None
|
|
18
|
+
commands_dir = ".claude/commands"
|
|
19
|
+
rule_extension = None
|
|
20
|
+
command_extension = ".md"
|
|
21
|
+
|
|
22
|
+
mcp_global_path = ".claude.json"
|
|
23
|
+
mcp_project_path = ".mcp.json"
|
|
24
|
+
|
|
25
|
+
def bundle_rules(
|
|
26
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Claude Code doesn't support rules, only commands."""
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def bundle_commands(
|
|
32
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
33
|
+
) -> bool:
|
|
34
|
+
"""Bundle Claude Code command files (.md) into a single output file."""
|
|
35
|
+
commands_dir = self.commands_dir
|
|
36
|
+
if not commands_dir:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
commands_path = output_file.parent / commands_dir
|
|
40
|
+
if not commands_path.exists():
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
extension = self.command_extension
|
|
44
|
+
if not extension:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
command_files = list(commands_path.glob(f"*{extension}"))
|
|
48
|
+
if not command_files:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
ordered_commands = get_ordered_files(
|
|
52
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
content_parts: list[str] = []
|
|
56
|
+
for command_file in ordered_commands:
|
|
57
|
+
content = command_file.read_text().strip()
|
|
58
|
+
if not content:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
header = resolve_header_from_stem(
|
|
62
|
+
command_file.stem, section_globs if section_globs else {}
|
|
63
|
+
)
|
|
64
|
+
content_parts.append(f"## {header}\n\n")
|
|
65
|
+
content_parts.append(content)
|
|
66
|
+
content_parts.append("\n\n")
|
|
67
|
+
|
|
68
|
+
if not content_parts:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
output_file.write_text("".join(content_parts))
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def write_rule(
|
|
75
|
+
self,
|
|
76
|
+
content_lines: list[str],
|
|
77
|
+
filename: str,
|
|
78
|
+
rules_dir: Path,
|
|
79
|
+
glob_pattern: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Claude Code doesn't support rules."""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def write_command(
|
|
85
|
+
self,
|
|
86
|
+
content_lines: list[str],
|
|
87
|
+
filename: str,
|
|
88
|
+
commands_dir: Path,
|
|
89
|
+
section_name: str | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Write a Claude Code command file (.md) - plain markdown, no frontmatter."""
|
|
92
|
+
extension = self.command_extension or ".md"
|
|
93
|
+
filepath = commands_dir / f"{filename}{extension}"
|
|
94
|
+
|
|
95
|
+
trimmed = trim_content(content_lines)
|
|
96
|
+
filepath.write_text("".join(trimmed))
|
|
97
|
+
|
|
98
|
+
def generate_root_doc(
|
|
99
|
+
self,
|
|
100
|
+
general_lines: list[str],
|
|
101
|
+
rules_sections: dict[str, list[str]],
|
|
102
|
+
command_sections: dict[str, list[str]],
|
|
103
|
+
output_dir: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Generate CLAUDE.md from rules."""
|
|
106
|
+
content = self.build_root_doc_content(general_lines, rules_sections)
|
|
107
|
+
if content.strip():
|
|
108
|
+
(output_dir / "CLAUDE.md").write_text(content)
|