c2roo 1.0.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.
- c2roo/__init__.py +1 -0
- c2roo/cli.py +277 -0
- c2roo/converter/__init__.py +0 -0
- c2roo/converter/agent_converter.py +94 -0
- c2roo/converter/command_converter.py +32 -0
- c2roo/converter/hook_converter.py +43 -0
- c2roo/converter/mcp_converter.py +39 -0
- c2roo/converter/skill_converter.py +48 -0
- c2roo/models/__init__.py +16 -0
- c2roo/models/agent.py +11 -0
- c2roo/models/command.py +10 -0
- c2roo/models/hook.py +9 -0
- c2roo/models/mcp.py +14 -0
- c2roo/models/plugin.py +29 -0
- c2roo/models/skill.py +18 -0
- c2roo/parser/__init__.py +3 -0
- c2roo/parser/agent_parser.py +36 -0
- c2roo/parser/command_parser.py +22 -0
- c2roo/parser/frontmatter.py +28 -0
- c2roo/parser/hook_parser.py +29 -0
- c2roo/parser/mcp_parser.py +28 -0
- c2roo/parser/plugin_parser.py +62 -0
- c2roo/parser/skill_parser.py +49 -0
- c2roo/report.py +92 -0
- c2roo/sources/__init__.py +0 -0
- c2roo/sources/git_source.py +49 -0
- c2roo/sources/local_source.py +15 -0
- c2roo/sources/marketplace.py +134 -0
- c2roo/writer/__init__.py +3 -0
- c2roo/writer/roo_writer.py +130 -0
- c2roo-1.0.0.dist-info/METADATA +8 -0
- c2roo-1.0.0.dist-info/RECORD +34 -0
- c2roo-1.0.0.dist-info/WHEEL +4 -0
- c2roo-1.0.0.dist-info/entry_points.txt +2 -0
c2roo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
c2roo/cli.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from c2roo.converter.agent_converter import convert_agent
|
|
8
|
+
from c2roo.converter.command_converter import convert_command
|
|
9
|
+
from c2roo.converter.hook_converter import convert_hooks
|
|
10
|
+
from c2roo.converter.mcp_converter import convert_mcp
|
|
11
|
+
from c2roo.converter.skill_converter import convert_skill
|
|
12
|
+
from c2roo.parser.plugin_parser import parse_plugin
|
|
13
|
+
from c2roo.report import ConversionReport
|
|
14
|
+
from c2roo.sources.git_source import check_git_available, clone_repo, is_git_url
|
|
15
|
+
from c2roo.sources.local_source import resolve_local
|
|
16
|
+
from c2roo.sources.marketplace import MarketplaceRegistry
|
|
17
|
+
from c2roo.writer.roo_writer import RooWriter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_output_root(target_global: bool) -> Path:
|
|
21
|
+
if target_global:
|
|
22
|
+
return Path.home() / ".roo"
|
|
23
|
+
return Path.cwd() / ".roo"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_conversion(plugin_path: Path, output_root: Path, force: bool, dry_run: bool) -> None:
|
|
27
|
+
"""Shared parse -> convert -> write pipeline."""
|
|
28
|
+
plugin = parse_plugin(plugin_path)
|
|
29
|
+
writer = RooWriter(output_root=output_root, force=force, dry_run=dry_run)
|
|
30
|
+
report = ConversionReport(plugin_name=plugin.metadata.name)
|
|
31
|
+
|
|
32
|
+
for skill_ir in plugin.skills:
|
|
33
|
+
converted_skill = convert_skill(skill_ir)
|
|
34
|
+
source_dir = plugin_path / "skills" / skill_ir.name
|
|
35
|
+
writer.write_skill(converted_skill, source_dir)
|
|
36
|
+
report.add_skill(converted_skill.name, dropped=converted_skill.dropped_fields)
|
|
37
|
+
|
|
38
|
+
for cmd_ir in plugin.commands:
|
|
39
|
+
converted_cmd = convert_command(cmd_ir)
|
|
40
|
+
writer.write_command(converted_cmd)
|
|
41
|
+
report.add_command(converted_cmd.name, dropped=converted_cmd.dropped_fields)
|
|
42
|
+
|
|
43
|
+
for agent_ir in plugin.agents:
|
|
44
|
+
converted_agent = convert_agent(agent_ir)
|
|
45
|
+
writer.write_agent(converted_agent)
|
|
46
|
+
report.add_agent(agent_ir.name)
|
|
47
|
+
|
|
48
|
+
if plugin.hooks:
|
|
49
|
+
hooks_content = convert_hooks(plugin.hooks)
|
|
50
|
+
writer.write_hooks(hooks_content)
|
|
51
|
+
report.add_hooks(count=len(plugin.hooks))
|
|
52
|
+
|
|
53
|
+
if plugin.mcp_servers:
|
|
54
|
+
mcp_data = convert_mcp(plugin.mcp_servers, output_root)
|
|
55
|
+
writer.write_mcp(mcp_data)
|
|
56
|
+
report.add_mcp(count=len(plugin.mcp_servers))
|
|
57
|
+
|
|
58
|
+
report.print(str(output_root))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@click.group()
|
|
62
|
+
@click.version_option()
|
|
63
|
+
def main() -> None:
|
|
64
|
+
"""Convert Claude Code plugins to Roo Code format."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- marketplace subcommands ---
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@main.group()
|
|
71
|
+
def marketplace() -> None:
|
|
72
|
+
"""Browse and manage plugin marketplace sources."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@marketplace.command("browse")
|
|
76
|
+
@click.option("--source", default=None, help="Filter to a specific marketplace source.")
|
|
77
|
+
def marketplace_browse(source: str | None) -> None:
|
|
78
|
+
"""List plugins from registered marketplaces."""
|
|
79
|
+
from rich.console import Console
|
|
80
|
+
from rich.table import Table
|
|
81
|
+
|
|
82
|
+
registry = MarketplaceRegistry()
|
|
83
|
+
sources = registry.list_sources()
|
|
84
|
+
if source:
|
|
85
|
+
sources = [s for s in sources if s["name"] == source]
|
|
86
|
+
|
|
87
|
+
console = Console()
|
|
88
|
+
table = Table(title="Available Plugins")
|
|
89
|
+
table.add_column("Name", style="cyan")
|
|
90
|
+
table.add_column("Description")
|
|
91
|
+
table.add_column("Source", style="green")
|
|
92
|
+
|
|
93
|
+
for src in sources:
|
|
94
|
+
try:
|
|
95
|
+
plugins = registry.fetch_marketplace_json(src)
|
|
96
|
+
for plugin in plugins:
|
|
97
|
+
table.add_row(
|
|
98
|
+
str(plugin.get("name", "?")),
|
|
99
|
+
str(plugin.get("description", "")),
|
|
100
|
+
src["name"],
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
console.print(f"[yellow]Warning: could not fetch {src['name']}: {e}[/yellow]")
|
|
104
|
+
|
|
105
|
+
console.print(table)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@marketplace.command("add")
|
|
109
|
+
@click.argument("repo")
|
|
110
|
+
@click.option("--name", required=True, help="Short name for this marketplace.")
|
|
111
|
+
@click.option("--description", default="", help="Description of this marketplace.")
|
|
112
|
+
def marketplace_add(repo: str, name: str, description: str) -> None:
|
|
113
|
+
"""Register a new marketplace source (owner/repo format)."""
|
|
114
|
+
registry = MarketplaceRegistry()
|
|
115
|
+
registry.add_source(name, repo, description)
|
|
116
|
+
click.echo(f"Added marketplace '{name}' ({repo})")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@marketplace.command("list")
|
|
120
|
+
def marketplace_list() -> None:
|
|
121
|
+
"""Show registered marketplace sources."""
|
|
122
|
+
from rich.console import Console
|
|
123
|
+
from rich.table import Table
|
|
124
|
+
|
|
125
|
+
registry = MarketplaceRegistry()
|
|
126
|
+
sources = registry.list_sources()
|
|
127
|
+
|
|
128
|
+
console = Console()
|
|
129
|
+
table = Table(title="Registered Marketplaces")
|
|
130
|
+
table.add_column("Name", style="cyan")
|
|
131
|
+
table.add_column("Repo", style="green")
|
|
132
|
+
table.add_column("Description")
|
|
133
|
+
|
|
134
|
+
for src in sources:
|
|
135
|
+
table.add_row(src["name"], src.get("repo", ""), src.get("description", ""))
|
|
136
|
+
|
|
137
|
+
console.print(table)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@marketplace.command("remove")
|
|
141
|
+
@click.argument("name")
|
|
142
|
+
def marketplace_remove(name: str) -> None:
|
|
143
|
+
"""Remove a marketplace source."""
|
|
144
|
+
registry = MarketplaceRegistry()
|
|
145
|
+
registry.remove_source(name)
|
|
146
|
+
click.echo(f"Removed marketplace '{name}'")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- convert command ---
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@main.command()
|
|
153
|
+
@click.argument("source")
|
|
154
|
+
@click.option(
|
|
155
|
+
"--global", "target_global", is_flag=True, default=False, help="Install to ~/.roo/ (global)."
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"--project", "target_project", is_flag=True, default=False, help="Install to .roo/ (project)."
|
|
159
|
+
)
|
|
160
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be converted.")
|
|
161
|
+
@click.option("--force", is_flag=True, default=False, help="Overwrite existing files.")
|
|
162
|
+
def convert(
|
|
163
|
+
source: str,
|
|
164
|
+
target_global: bool,
|
|
165
|
+
target_project: bool,
|
|
166
|
+
dry_run: bool,
|
|
167
|
+
force: bool,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Convert a Claude Code plugin from a local path or git URL."""
|
|
170
|
+
if not target_global and not target_project:
|
|
171
|
+
raise click.UsageError("Must specify --global or --project.")
|
|
172
|
+
if target_global and target_project:
|
|
173
|
+
raise click.UsageError("Cannot specify both --global and --project.")
|
|
174
|
+
|
|
175
|
+
temp_dir = None
|
|
176
|
+
if is_git_url(source):
|
|
177
|
+
check_git_available()
|
|
178
|
+
temp_dir = tempfile.mkdtemp(prefix="c2roo-")
|
|
179
|
+
plugin_path = clone_repo(source, dest=Path(temp_dir) / "plugin")
|
|
180
|
+
else:
|
|
181
|
+
plugin_path = resolve_local(source)
|
|
182
|
+
|
|
183
|
+
output_root = _resolve_output_root(target_global)
|
|
184
|
+
|
|
185
|
+
if dry_run:
|
|
186
|
+
click.echo("[DRY RUN] No files will be written.\n")
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
_run_conversion(plugin_path, output_root, force, dry_run)
|
|
190
|
+
finally:
|
|
191
|
+
if temp_dir:
|
|
192
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --- install command ---
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@main.command()
|
|
199
|
+
@click.argument("plugin_name")
|
|
200
|
+
@click.option(
|
|
201
|
+
"--global", "target_global", is_flag=True, default=False, help="Install to ~/.roo/ (global)."
|
|
202
|
+
)
|
|
203
|
+
@click.option(
|
|
204
|
+
"--project", "target_project", is_flag=True, default=False, help="Install to .roo/ (project)."
|
|
205
|
+
)
|
|
206
|
+
@click.option("--source", default=None, help="Which marketplace to search.")
|
|
207
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be converted.")
|
|
208
|
+
@click.option("--force", is_flag=True, default=False, help="Overwrite existing files.")
|
|
209
|
+
def install(
|
|
210
|
+
plugin_name: str,
|
|
211
|
+
target_global: bool,
|
|
212
|
+
target_project: bool,
|
|
213
|
+
source: str | None,
|
|
214
|
+
dry_run: bool,
|
|
215
|
+
force: bool,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Install a plugin from a marketplace, converting to Roo format."""
|
|
218
|
+
if not target_global and not target_project:
|
|
219
|
+
raise click.UsageError("Must specify --global or --project.")
|
|
220
|
+
if target_global and target_project:
|
|
221
|
+
raise click.UsageError("Cannot specify both --global and --project.")
|
|
222
|
+
|
|
223
|
+
check_git_available()
|
|
224
|
+
|
|
225
|
+
registry = MarketplaceRegistry()
|
|
226
|
+
result = registry.search_plugin(plugin_name, source_filter=source)
|
|
227
|
+
|
|
228
|
+
if result is None:
|
|
229
|
+
raise click.ClickException(
|
|
230
|
+
f"Plugin '{plugin_name}' not found in any registered marketplace."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
plugin_entry, mkt_source = result
|
|
234
|
+
click.echo(f"Found '{plugin_name}' in marketplace '{mkt_source['name']}'")
|
|
235
|
+
|
|
236
|
+
output_root = _resolve_output_root(target_global)
|
|
237
|
+
|
|
238
|
+
if dry_run:
|
|
239
|
+
click.echo("[DRY RUN] No files will be written.\n")
|
|
240
|
+
|
|
241
|
+
temp_dir = tempfile.mkdtemp(prefix="c2roo-")
|
|
242
|
+
try:
|
|
243
|
+
plugin_source = plugin_entry.get("source")
|
|
244
|
+
|
|
245
|
+
if isinstance(plugin_source, str):
|
|
246
|
+
# Relative path — clone the marketplace repo first
|
|
247
|
+
mkt_repo_url = f"https://github.com/{mkt_source['repo']}.git"
|
|
248
|
+
mkt_dir = Path(temp_dir) / "marketplace"
|
|
249
|
+
clone_repo(mkt_repo_url, dest=mkt_dir)
|
|
250
|
+
plugin_path = mkt_dir / plugin_source
|
|
251
|
+
elif isinstance(plugin_source, dict):
|
|
252
|
+
source_type = plugin_source.get("source")
|
|
253
|
+
if source_type == "url":
|
|
254
|
+
url = plugin_source["url"]
|
|
255
|
+
sha = plugin_source.get("sha")
|
|
256
|
+
plugin_path = clone_repo(url, dest=Path(temp_dir) / "plugin", sha=sha)
|
|
257
|
+
elif source_type == "git-subdir":
|
|
258
|
+
repo_url = plugin_source["url"]
|
|
259
|
+
if not repo_url.startswith("http"):
|
|
260
|
+
repo_url = f"https://github.com/{repo_url}.git"
|
|
261
|
+
sha = plugin_source.get("sha")
|
|
262
|
+
subdir = plugin_source.get("path")
|
|
263
|
+
plugin_path = clone_repo(
|
|
264
|
+
repo_url, dest=Path(temp_dir) / "plugin", sha=sha, subdir=subdir
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
raise click.ClickException(f"Unknown source type: {source_type}")
|
|
268
|
+
else:
|
|
269
|
+
raise click.ClickException(f"Invalid source format for plugin '{plugin_name}'")
|
|
270
|
+
|
|
271
|
+
if not (plugin_path / ".claude-plugin" / "plugin.json").exists():
|
|
272
|
+
raise click.ClickException("Downloaded plugin has no .claude-plugin/plugin.json")
|
|
273
|
+
|
|
274
|
+
_run_conversion(plugin_path, output_root, force, dry_run)
|
|
275
|
+
|
|
276
|
+
finally:
|
|
277
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from c2roo.models.agent import Agent
|
|
4
|
+
|
|
5
|
+
# Mapping from Claude Code tool names to Roo tool groups
|
|
6
|
+
TOOL_TO_GROUP = {
|
|
7
|
+
"Read": "read",
|
|
8
|
+
"Grep": "read",
|
|
9
|
+
"Glob": "read",
|
|
10
|
+
"LS": "read",
|
|
11
|
+
"NotebookRead": "read",
|
|
12
|
+
"Write": "edit",
|
|
13
|
+
"Edit": "edit",
|
|
14
|
+
"MultiEdit": "edit",
|
|
15
|
+
"NotebookEdit": "edit",
|
|
16
|
+
"Bash": "command",
|
|
17
|
+
"BashOutput": "command",
|
|
18
|
+
"KillShell": "command",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
MCP_TOOLS = {"WebFetch", "WebSearch", "TodoWrite"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _humanize_name(slug: str) -> str:
|
|
25
|
+
"""Convert kebab-case slug to Title Case name."""
|
|
26
|
+
return " ".join(word.capitalize() for word in slug.split("-"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_role_definition(body: str, max_length: int = 500) -> str:
|
|
30
|
+
"""Extract the first paragraph of the body as a role definition."""
|
|
31
|
+
paragraphs = body.split("\n\n")
|
|
32
|
+
first = paragraphs[0].strip() if paragraphs else body.strip()
|
|
33
|
+
if len(first) > max_length:
|
|
34
|
+
first = first[:max_length].rsplit(" ", 1)[0] + "..."
|
|
35
|
+
return first
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _map_tools_to_groups(tools: list[str]) -> list[str]:
|
|
39
|
+
"""Map Claude Code tool names to Roo tool groups."""
|
|
40
|
+
groups = set()
|
|
41
|
+
for tool in tools:
|
|
42
|
+
if tool in TOOL_TO_GROUP:
|
|
43
|
+
groups.add(TOOL_TO_GROUP[tool])
|
|
44
|
+
elif tool in MCP_TOOLS:
|
|
45
|
+
groups.add("mcp")
|
|
46
|
+
return sorted(groups)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ConvertedAgent:
|
|
51
|
+
mode: dict[str, object]
|
|
52
|
+
rules_content: str
|
|
53
|
+
slug: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def convert_agent(agent: Agent) -> ConvertedAgent:
|
|
57
|
+
"""Convert an Agent IR to a Roo custom mode + rules file."""
|
|
58
|
+
slug = agent.name
|
|
59
|
+
groups = _map_tools_to_groups(agent.tools)
|
|
60
|
+
|
|
61
|
+
mode: dict[str, object] = {
|
|
62
|
+
"slug": slug,
|
|
63
|
+
"name": _humanize_name(slug),
|
|
64
|
+
"description": agent.description,
|
|
65
|
+
"roleDefinition": _extract_role_definition(agent.body),
|
|
66
|
+
"groups": groups,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines = [
|
|
70
|
+
f"# {_humanize_name(slug)}",
|
|
71
|
+
"",
|
|
72
|
+
f"> Converted from Claude Code agent: `{agent.name}`",
|
|
73
|
+
"",
|
|
74
|
+
agent.body,
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
footer_parts = []
|
|
78
|
+
if agent.model:
|
|
79
|
+
footer_parts.append(f"Originally configured for model: {agent.model}")
|
|
80
|
+
if agent.tools:
|
|
81
|
+
footer_parts.append(f"Original tool list: {', '.join(agent.tools)}")
|
|
82
|
+
|
|
83
|
+
if footer_parts:
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("---")
|
|
86
|
+
lines.append("")
|
|
87
|
+
for part in footer_parts:
|
|
88
|
+
lines.append(f"*{part}*")
|
|
89
|
+
|
|
90
|
+
return ConvertedAgent(
|
|
91
|
+
mode=mode,
|
|
92
|
+
rules_content="\n".join(lines),
|
|
93
|
+
slug=slug,
|
|
94
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from c2roo.models.command import Command
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ConvertedCommand:
|
|
8
|
+
frontmatter: dict[str, object]
|
|
9
|
+
body: str
|
|
10
|
+
name: str
|
|
11
|
+
dropped_fields: list[str] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def convert_command(cmd: Command) -> ConvertedCommand:
|
|
15
|
+
"""Convert a Command IR to Roo slash command format."""
|
|
16
|
+
frontmatter: dict[str, object] = {}
|
|
17
|
+
|
|
18
|
+
if cmd.description:
|
|
19
|
+
frontmatter["description"] = cmd.description
|
|
20
|
+
if cmd.argument_hint:
|
|
21
|
+
frontmatter["argument-hint"] = cmd.argument_hint
|
|
22
|
+
|
|
23
|
+
dropped = []
|
|
24
|
+
if cmd.allowed_tools:
|
|
25
|
+
dropped.append("allowed-tools")
|
|
26
|
+
|
|
27
|
+
return ConvertedCommand(
|
|
28
|
+
frontmatter=frontmatter,
|
|
29
|
+
body=cmd.body,
|
|
30
|
+
name=cmd.name,
|
|
31
|
+
dropped_fields=sorted(dropped),
|
|
32
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from c2roo.models.hook import Hook
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def convert_hooks(hooks: list[Hook]) -> str:
|
|
5
|
+
"""Convert Hook IRs to a Roo guidance rules markdown document."""
|
|
6
|
+
if not hooks:
|
|
7
|
+
return ""
|
|
8
|
+
|
|
9
|
+
lines = [
|
|
10
|
+
"# Converted from Claude Code hooks",
|
|
11
|
+
"",
|
|
12
|
+
"> These hooks were originally enforced automatically by Claude Code.",
|
|
13
|
+
"> Roo Code does not support hooks. The guidance below describes the",
|
|
14
|
+
"> original behavior — consider running these manually or setting up",
|
|
15
|
+
"> an equivalent workflow.",
|
|
16
|
+
"",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Group by event type
|
|
20
|
+
by_event: dict[str, list[Hook]] = {}
|
|
21
|
+
for hook in hooks:
|
|
22
|
+
by_event.setdefault(hook.event, []).append(hook)
|
|
23
|
+
|
|
24
|
+
for event, event_hooks in by_event.items():
|
|
25
|
+
lines.append(f"## {event}")
|
|
26
|
+
lines.append("")
|
|
27
|
+
|
|
28
|
+
for hook in event_hooks:
|
|
29
|
+
if hook.matcher:
|
|
30
|
+
lines.append(f"**Trigger:** when tools matching `{hook.matcher}` are used")
|
|
31
|
+
lines.append("")
|
|
32
|
+
|
|
33
|
+
lines.append("**Original command:**")
|
|
34
|
+
lines.append("```")
|
|
35
|
+
lines.append(hook.command)
|
|
36
|
+
lines.append("```")
|
|
37
|
+
|
|
38
|
+
if hook.timeout:
|
|
39
|
+
lines.append(f"*Timeout: {hook.timeout}s*")
|
|
40
|
+
|
|
41
|
+
lines.append("")
|
|
42
|
+
|
|
43
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from c2roo.models.mcp import McpServer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _resolve_plugin_root(value: str, install_path: Path) -> str:
|
|
7
|
+
"""Replace ${CLAUDE_PLUGIN_ROOT} with the actual install path."""
|
|
8
|
+
return value.replace("${CLAUDE_PLUGIN_ROOT}", str(install_path))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def convert_mcp(servers: dict[str, McpServer], install_path: Path) -> dict[str, dict[str, object]]:
|
|
12
|
+
"""Convert McpServer IRs to a Roo mcp.json-compatible dict."""
|
|
13
|
+
if not servers:
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
result: dict[str, dict[str, object]] = {}
|
|
17
|
+
for name, server in servers.items():
|
|
18
|
+
entry: dict[str, object] = {}
|
|
19
|
+
|
|
20
|
+
if server.command:
|
|
21
|
+
entry["command"] = _resolve_plugin_root(server.command, install_path)
|
|
22
|
+
if server.args:
|
|
23
|
+
entry["args"] = [_resolve_plugin_root(a, install_path) for a in server.args]
|
|
24
|
+
if server.env:
|
|
25
|
+
entry["env"] = {k: _resolve_plugin_root(v, install_path) for k, v in server.env.items()}
|
|
26
|
+
if server.url:
|
|
27
|
+
entry["url"] = server.url
|
|
28
|
+
if server.headers:
|
|
29
|
+
entry["headers"] = server.headers
|
|
30
|
+
if server.disabled:
|
|
31
|
+
entry["disabled"] = True
|
|
32
|
+
if server.always_allow:
|
|
33
|
+
entry["alwaysAllow"] = server.always_allow
|
|
34
|
+
if server.timeout:
|
|
35
|
+
entry["timeout"] = server.timeout
|
|
36
|
+
|
|
37
|
+
result[name] = entry
|
|
38
|
+
|
|
39
|
+
return result
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from c2roo.models.skill import Skill
|
|
4
|
+
|
|
5
|
+
CLAUDE_SPECIFIC_FIELDS = ["disable-model-invocation", "user-invocable", "context", "agent"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ConvertedSkill:
|
|
10
|
+
frontmatter: dict[str, object]
|
|
11
|
+
body: str
|
|
12
|
+
name: str
|
|
13
|
+
dropped_fields: list[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def convert_skill(skill: Skill) -> ConvertedSkill:
|
|
17
|
+
"""Convert a Skill IR to Roo skill format (cleaned frontmatter)."""
|
|
18
|
+
frontmatter: dict[str, object] = {
|
|
19
|
+
"name": skill.name,
|
|
20
|
+
"description": skill.description,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if skill.license:
|
|
24
|
+
frontmatter["license"] = skill.license
|
|
25
|
+
if skill.compatibility:
|
|
26
|
+
frontmatter["compatibility"] = skill.compatibility
|
|
27
|
+
if skill.metadata:
|
|
28
|
+
frontmatter["metadata"] = skill.metadata
|
|
29
|
+
if skill.allowed_tools:
|
|
30
|
+
frontmatter["allowed-tools"] = skill.allowed_tools
|
|
31
|
+
|
|
32
|
+
# Track which Claude-specific fields were present and dropped
|
|
33
|
+
dropped = []
|
|
34
|
+
if skill.agent is not None:
|
|
35
|
+
dropped.append("agent")
|
|
36
|
+
if skill.context is not None:
|
|
37
|
+
dropped.append("context")
|
|
38
|
+
if skill.disable_model_invocation is not None:
|
|
39
|
+
dropped.append("disable-model-invocation")
|
|
40
|
+
if skill.user_invocable is not None:
|
|
41
|
+
dropped.append("user-invocable")
|
|
42
|
+
|
|
43
|
+
return ConvertedSkill(
|
|
44
|
+
frontmatter=frontmatter,
|
|
45
|
+
body=skill.body,
|
|
46
|
+
name=skill.name,
|
|
47
|
+
dropped_fields=sorted(dropped),
|
|
48
|
+
)
|
c2roo/models/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from c2roo.models.agent import Agent
|
|
2
|
+
from c2roo.models.command import Command
|
|
3
|
+
from c2roo.models.hook import Hook
|
|
4
|
+
from c2roo.models.mcp import McpServer
|
|
5
|
+
from c2roo.models.plugin import Plugin, PluginMetadata
|
|
6
|
+
from c2roo.models.skill import Skill
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Agent",
|
|
10
|
+
"Command",
|
|
11
|
+
"Hook",
|
|
12
|
+
"McpServer",
|
|
13
|
+
"Plugin",
|
|
14
|
+
"PluginMetadata",
|
|
15
|
+
"Skill",
|
|
16
|
+
]
|
c2roo/models/agent.py
ADDED
c2roo/models/command.py
ADDED
c2roo/models/hook.py
ADDED
c2roo/models/mcp.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class McpServer:
|
|
6
|
+
name: str
|
|
7
|
+
command: str | None = None
|
|
8
|
+
args: list[str] = field(default_factory=list)
|
|
9
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
10
|
+
url: str | None = None
|
|
11
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
12
|
+
disabled: bool = False
|
|
13
|
+
always_allow: list[str] = field(default_factory=list)
|
|
14
|
+
timeout: int | None = None
|
c2roo/models/plugin.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from c2roo.models.agent import Agent
|
|
5
|
+
from c2roo.models.command import Command
|
|
6
|
+
from c2roo.models.hook import Hook
|
|
7
|
+
from c2roo.models.mcp import McpServer
|
|
8
|
+
from c2roo.models.skill import Skill
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PluginMetadata:
|
|
13
|
+
name: str
|
|
14
|
+
description: str
|
|
15
|
+
version: str | None = None
|
|
16
|
+
author: str | None = None
|
|
17
|
+
homepage: str | None = None
|
|
18
|
+
license: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Plugin:
|
|
23
|
+
metadata: PluginMetadata
|
|
24
|
+
source_path: Path
|
|
25
|
+
skills: list[Skill] = field(default_factory=list)
|
|
26
|
+
commands: list[Command] = field(default_factory=list)
|
|
27
|
+
agents: list[Agent] = field(default_factory=list)
|
|
28
|
+
hooks: list[Hook] = field(default_factory=list)
|
|
29
|
+
mcp_servers: dict[str, McpServer] = field(default_factory=dict)
|
c2roo/models/skill.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Skill:
|
|
7
|
+
name: str
|
|
8
|
+
description: str
|
|
9
|
+
body: str
|
|
10
|
+
license: str | None = None
|
|
11
|
+
compatibility: str | None = None
|
|
12
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
13
|
+
allowed_tools: str | None = None
|
|
14
|
+
resources: list[Path] = field(default_factory=list)
|
|
15
|
+
disable_model_invocation: bool | None = None
|
|
16
|
+
user_invocable: bool | None = None
|
|
17
|
+
context: str | None = None
|
|
18
|
+
agent: str | None = None
|
c2roo/parser/__init__.py
ADDED