kiro-cc-plugins 0.1.0__tar.gz
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.
- kiro_cc_plugins-0.1.0/.gitignore +6 -0
- kiro_cc_plugins-0.1.0/PKG-INFO +8 -0
- kiro_cc_plugins-0.1.0/README.md +72 -0
- kiro_cc_plugins-0.1.0/pyproject.toml +20 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/__init__.py +1 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/cli.py +439 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/converter.py +213 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/models.py +57 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/registry.py +55 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/scanner.py +122 -0
- kiro_cc_plugins-0.1.0/src/kiro_cc_plugin_converter/source.py +220 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# kiro-cc-plugins
|
|
2
|
+
|
|
3
|
+
Install [Claude Code plugins](https://code.claude.com/docs/en/plugins) into [Kiro](https://kiro.dev).
|
|
4
|
+
|
|
5
|
+
Supports marketplace repos (like the [official Anthropic marketplace](https://github.com/anthropics/claude-plugins-official)) and standalone plugin/skill repos.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# One-shot (no install needed)
|
|
11
|
+
uvx --from git+https://github.com/anthropics/kiro-cc-plugins.git kiro-cc-plugins --help
|
|
12
|
+
|
|
13
|
+
# Or install permanently
|
|
14
|
+
uv tool install git+https://github.com/anthropics/kiro-cc-plugins.git
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Add the official Claude Code marketplace
|
|
21
|
+
kiro-cc-plugins source add https://github.com/anthropics/claude-plugins-official
|
|
22
|
+
|
|
23
|
+
# Add the Anthropic skills repo
|
|
24
|
+
kiro-cc-plugins source add https://github.com/anthropics/skills.git
|
|
25
|
+
|
|
26
|
+
# Browse available plugins
|
|
27
|
+
kiro-cc-plugins list
|
|
28
|
+
|
|
29
|
+
# List all skills across sources
|
|
30
|
+
kiro-cc-plugins list --skills
|
|
31
|
+
|
|
32
|
+
# Install a plugin
|
|
33
|
+
kiro-cc-plugins add feature-dev
|
|
34
|
+
|
|
35
|
+
# Install to current project only
|
|
36
|
+
kiro-cc-plugins add feature-dev --scope workspace
|
|
37
|
+
|
|
38
|
+
# Install only specific component types
|
|
39
|
+
kiro-cc-plugins add plugin-dev --only skill
|
|
40
|
+
|
|
41
|
+
# Install from any git URL directly
|
|
42
|
+
kiro-cc-plugins add --git https://github.com/user/my-cc-plugin
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `source add <url>` | Clone a marketplace or plugin repo |
|
|
50
|
+
| `source list` | List registered sources |
|
|
51
|
+
| `source update [name]` | Git pull sources |
|
|
52
|
+
| `source remove <name>` | Remove a source |
|
|
53
|
+
| `list [plugin]` | List available plugins or show plugin details |
|
|
54
|
+
| `list --installed` | Show installed plugins |
|
|
55
|
+
| `list --skills` | List all skills across sources |
|
|
56
|
+
| `list --agents` | List all agents across sources |
|
|
57
|
+
| `add <name>` | Convert and install a plugin |
|
|
58
|
+
| `add --all` | Install all local plugins |
|
|
59
|
+
| `add --scope workspace` | Install to `.kiro/` instead of `~/.kiro/` |
|
|
60
|
+
| `update [name] / --all` | Re-sync from source and re-convert |
|
|
61
|
+
| `delete <name> / --all` | Remove converted plugins |
|
|
62
|
+
|
|
63
|
+
## Conversion Mapping
|
|
64
|
+
|
|
65
|
+
| Claude Code | Kiro | Format |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `skills/*/SKILL.md` | `~/.kiro/skills/{source}--{plugin}--{skill}/` | Direct copy (compatible format) |
|
|
68
|
+
| `commands/*.md` | `~/.kiro/agents/{name}.json` | Prompt → agent JSON |
|
|
69
|
+
| `agents/*.md` | `~/.kiro/agents/{name}.json` | Prompt → agent JSON, tools mapped |
|
|
70
|
+
| `.mcp.json` | `~/.kiro/agents/{plugin}-mcp.json` | MCP config → agent with mcpServers |
|
|
71
|
+
|
|
72
|
+
Hooks and LSP configs are skipped (no Kiro equivalent). Existing agents with the same name are not overwritten.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kiro-cc-plugins"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Install Claude Code plugins into Kiro"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"click>=8.0",
|
|
8
|
+
"rich>=13.0",
|
|
9
|
+
"pyyaml>=6.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
kiro-cc-plugins = "kiro_cc_plugin_converter.cli:cli"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/kiro_cc_plugin_converter"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""kiro-cc-plugin-converter: Convert Claude Code plugins to Kiro format."""
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from . import source as src_mod
|
|
12
|
+
from . import scanner, converter, registry
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def _default_source() -> str | None:
|
|
20
|
+
sources = src_mod.list_sources()
|
|
21
|
+
if len(sources) == 1:
|
|
22
|
+
return sources[0]["name"]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_source(source_name: str | None) -> str:
|
|
27
|
+
if source_name:
|
|
28
|
+
return source_name
|
|
29
|
+
default = _default_source()
|
|
30
|
+
if default:
|
|
31
|
+
return default
|
|
32
|
+
raise click.ClickException("Multiple sources found. Specify --source <name>.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_plugin_source(plugin_name: str) -> tuple[str, list[dict]] | None:
|
|
36
|
+
"""Search all sources for a plugin by name. Returns (source_name, plugins) or None."""
|
|
37
|
+
matches = []
|
|
38
|
+
for s in src_mod.list_sources():
|
|
39
|
+
try:
|
|
40
|
+
plugins = src_mod.parse_marketplace(s["name"])
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
continue
|
|
43
|
+
if any(p["name"] == plugin_name for p in plugins):
|
|
44
|
+
matches.append((s["name"], plugins))
|
|
45
|
+
if len(matches) == 1:
|
|
46
|
+
return matches[0]
|
|
47
|
+
if len(matches) > 1:
|
|
48
|
+
names = ", ".join(m[0] for m in matches)
|
|
49
|
+
raise click.ClickException(f"'{plugin_name}' found in multiple sources: {names}. Specify --source <name>.")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_commit(source_name: str) -> str:
|
|
54
|
+
for s in src_mod.list_sources():
|
|
55
|
+
if s["name"] == source_name:
|
|
56
|
+
return s.get("commit", "")
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── source commands ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
@click.group()
|
|
63
|
+
def cli():
|
|
64
|
+
"""Convert Claude Code plugins to Kiro format."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.group("source")
|
|
68
|
+
def source_group():
|
|
69
|
+
"""Manage plugin sources (git repos)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@source_group.command("add")
|
|
73
|
+
@click.argument("url")
|
|
74
|
+
@click.option("--name", default=None, help="Override source name")
|
|
75
|
+
def source_add(url: str, name: str | None):
|
|
76
|
+
"""Clone a marketplace or plugin repo."""
|
|
77
|
+
with console.status(f"Cloning {url}..."):
|
|
78
|
+
s = src_mod.add_source(url, name)
|
|
79
|
+
console.print(f"[green]✓[/] Source [bold]{s.name}[/] added ({s.commit[:8]})")
|
|
80
|
+
|
|
81
|
+
plugins = src_mod.parse_marketplace(s.name)
|
|
82
|
+
local_count = sum(1 for p in plugins if p.get("_local_path"))
|
|
83
|
+
console.print(f" {len(plugins)} plugins found, {local_count} available locally")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@source_group.command("list")
|
|
87
|
+
def source_list():
|
|
88
|
+
"""List registered sources."""
|
|
89
|
+
sources = src_mod.list_sources()
|
|
90
|
+
if not sources:
|
|
91
|
+
console.print("[dim]No sources. Run: kiro-cc source add <url>[/]")
|
|
92
|
+
return
|
|
93
|
+
t = Table(title="Sources")
|
|
94
|
+
t.add_column("Name", style="bold")
|
|
95
|
+
t.add_column("URL")
|
|
96
|
+
t.add_column("Commit")
|
|
97
|
+
for s in sources:
|
|
98
|
+
t.add_row(s["name"], s["url"], s.get("commit", "")[:8])
|
|
99
|
+
console.print(t)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@source_group.command("remove")
|
|
103
|
+
@click.argument("name")
|
|
104
|
+
def source_remove(name: str):
|
|
105
|
+
"""Remove a source."""
|
|
106
|
+
src_mod.remove_source(name)
|
|
107
|
+
console.print(f"[green]✓[/] Source [bold]{name}[/] removed")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@source_group.command("update")
|
|
111
|
+
@click.argument("name", required=False)
|
|
112
|
+
def source_update(name: str | None):
|
|
113
|
+
"""Git pull a source."""
|
|
114
|
+
if name is None:
|
|
115
|
+
for s in src_mod.list_sources():
|
|
116
|
+
commit = src_mod.update_source(s["name"])
|
|
117
|
+
console.print(f"[green]✓[/] {s['name']} updated ({commit[:8]})")
|
|
118
|
+
else:
|
|
119
|
+
commit = src_mod.update_source(name)
|
|
120
|
+
console.print(f"[green]✓[/] {name} updated ({commit[:8]})")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── list ─────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
@cli.command("list")
|
|
126
|
+
@click.argument("plugin_name", required=False)
|
|
127
|
+
@click.option("--source", "source_name", default=None, help="Source name")
|
|
128
|
+
@click.option("--installed", is_flag=True, help="Show installed plugins only")
|
|
129
|
+
@click.option("--only", "only_types", default=None, help="Filter by type: skill,agent,command,mcp")
|
|
130
|
+
@click.option("--skills", is_flag=True, help="List all skills across all sources")
|
|
131
|
+
@click.option("--agents", is_flag=True, help="List all agents across all sources")
|
|
132
|
+
def list_cmd(plugin_name: str | None, source_name: str | None, installed: bool, only_types: str | None, skills: bool, agents: bool):
|
|
133
|
+
"""List available or installed plugins."""
|
|
134
|
+
type_filter = set(only_types.split(",")) if only_types else None
|
|
135
|
+
|
|
136
|
+
if skills or agents:
|
|
137
|
+
_list_all_components("skill" if skills else "agent", source_name)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if installed:
|
|
141
|
+
_list_installed(type_filter)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
source_name = _resolve_source(source_name)
|
|
145
|
+
plugins = src_mod.parse_marketplace(source_name)
|
|
146
|
+
|
|
147
|
+
if plugin_name:
|
|
148
|
+
_show_plugin_detail(source_name, plugins, plugin_name, type_filter)
|
|
149
|
+
else:
|
|
150
|
+
_list_plugins(plugins, type_filter)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _list_all_components(comp_type: str, source_name: str | None):
|
|
154
|
+
"""List all components of a given type across sources."""
|
|
155
|
+
sources = [{"name": source_name}] if source_name else src_mod.list_sources()
|
|
156
|
+
installed_targets = set()
|
|
157
|
+
for p in registry.get_installed():
|
|
158
|
+
for c in p.get("components", []):
|
|
159
|
+
installed_targets.add(c.get("name", ""))
|
|
160
|
+
|
|
161
|
+
label = comp_type.capitalize() + "s"
|
|
162
|
+
t = Table(title=f"All {label}")
|
|
163
|
+
t.add_column("Name", style="bold")
|
|
164
|
+
t.add_column("Plugin")
|
|
165
|
+
t.add_column("Source")
|
|
166
|
+
t.add_column("Description", max_width=45)
|
|
167
|
+
t.add_column("Status", justify="center")
|
|
168
|
+
|
|
169
|
+
total = 0
|
|
170
|
+
for src in sources:
|
|
171
|
+
sn = src["name"]
|
|
172
|
+
try:
|
|
173
|
+
plugins = src_mod.parse_marketplace(sn)
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
continue
|
|
176
|
+
for plugin in plugins:
|
|
177
|
+
lp = plugin.get("_local_path")
|
|
178
|
+
if not lp or not Path(lp).is_dir():
|
|
179
|
+
continue
|
|
180
|
+
comps = scanner.scan_plugin(Path(lp), plugin.get("_skill_filter"))
|
|
181
|
+
for c in comps:
|
|
182
|
+
if c.type != comp_type:
|
|
183
|
+
continue
|
|
184
|
+
status = "[green]✓[/]" if c.name in installed_targets else "[dim]○[/]"
|
|
185
|
+
desc = c.frontmatter.get("description", "")[:45]
|
|
186
|
+
t.add_row(c.name, plugin["name"], sn, desc, status)
|
|
187
|
+
total += 1
|
|
188
|
+
|
|
189
|
+
console.print(t)
|
|
190
|
+
console.print(f"\n[dim]{total} {comp_type}s found across local plugins.[/]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _list_installed(type_filter: set | None):
|
|
194
|
+
items = registry.get_installed()
|
|
195
|
+
if not items:
|
|
196
|
+
console.print("[dim]No plugins installed.[/]")
|
|
197
|
+
return
|
|
198
|
+
t = Table(title="Installed Plugins")
|
|
199
|
+
t.add_column("Plugin", style="bold")
|
|
200
|
+
t.add_column("Source")
|
|
201
|
+
t.add_column("Components")
|
|
202
|
+
t.add_column("Installed")
|
|
203
|
+
for p in items:
|
|
204
|
+
comps = p.get("components", [])
|
|
205
|
+
if type_filter:
|
|
206
|
+
comps = [c for c in comps if c["type"] in type_filter]
|
|
207
|
+
summary = ", ".join(f"{c['type']}:{c['name']}" for c in comps)
|
|
208
|
+
t.add_row(p["plugin_name"], p["source_name"], summary, p.get("installed_at", "")[:10])
|
|
209
|
+
console.print(t)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _list_plugins(plugins: list[dict], type_filter: set | None):
|
|
213
|
+
installed_names = {p["plugin_name"] for p in registry.get_installed()}
|
|
214
|
+
|
|
215
|
+
t = Table(title="Available Plugins")
|
|
216
|
+
t.add_column("Name", style="bold")
|
|
217
|
+
t.add_column("Description", max_width=60)
|
|
218
|
+
t.add_column("Category")
|
|
219
|
+
t.add_column("Status", justify="center")
|
|
220
|
+
|
|
221
|
+
for p in plugins:
|
|
222
|
+
name = p["name"]
|
|
223
|
+
lp = p.get("_local_path")
|
|
224
|
+
if name in installed_names:
|
|
225
|
+
status = "[green]✓[/]"
|
|
226
|
+
elif lp and Path(lp).is_dir():
|
|
227
|
+
status = "[green]○[/]"
|
|
228
|
+
elif isinstance(p.get("source"), dict):
|
|
229
|
+
status = "[yellow]○[/]"
|
|
230
|
+
else:
|
|
231
|
+
status = "[red]✗[/]"
|
|
232
|
+
t.add_row(name, p.get("description", "")[:60], p.get("category", ""), status)
|
|
233
|
+
console.print(t)
|
|
234
|
+
installed_count = sum(1 for p in plugins if p["name"] in installed_names)
|
|
235
|
+
local_count = sum(1 for p in plugins if p.get("_local_path") and Path(p["_local_path"]).is_dir())
|
|
236
|
+
fetch_count = sum(1 for p in plugins if not (p.get("_local_path") and Path(p["_local_path"]).is_dir()) and isinstance(p.get("source"), dict))
|
|
237
|
+
console.print(f"\n[dim]{len(plugins)} plugins: [green]{installed_count} installed ✓[/], [green]{local_count} available ○[/], [yellow]{fetch_count} fetchable ○[/][/]")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _show_plugin_detail(source_name: str, plugins: list[dict], name: str, type_filter: set | None):
|
|
241
|
+
plugin = next((p for p in plugins if p["name"] == name), None)
|
|
242
|
+
if not plugin:
|
|
243
|
+
raise click.ClickException(f"Plugin '{name}' not found")
|
|
244
|
+
|
|
245
|
+
console.print(f"\n[bold]{name}[/]")
|
|
246
|
+
console.print(f" {plugin.get('description', '')}")
|
|
247
|
+
|
|
248
|
+
local_path = plugin.get("_local_path")
|
|
249
|
+
if not local_path or not Path(local_path).is_dir():
|
|
250
|
+
src = plugin.get("source", "")
|
|
251
|
+
if isinstance(src, dict):
|
|
252
|
+
src_type = src.get("source", "")
|
|
253
|
+
src_detail = src.get("url", "") or src.get("repo", "")
|
|
254
|
+
console.print(f"\n [dim]External ({src_type}): {src_detail}[/]")
|
|
255
|
+
console.print(f" [dim]Run 'kiro-cc add {name}' to fetch and install.[/]")
|
|
256
|
+
else:
|
|
257
|
+
console.print(f"\n [yellow]⚠ Not available locally[/]")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
components = scanner.scan_plugin(Path(local_path), plugin.get("_skill_filter"))
|
|
261
|
+
if type_filter:
|
|
262
|
+
components = [c for c in components if c.type in type_filter]
|
|
263
|
+
|
|
264
|
+
if not components:
|
|
265
|
+
console.print(" [dim]No convertible components found.[/]")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
t = Table()
|
|
269
|
+
t.add_column("Type", style="cyan")
|
|
270
|
+
t.add_column("Name", style="bold")
|
|
271
|
+
t.add_column("Description", max_width=50)
|
|
272
|
+
for c in components:
|
|
273
|
+
desc = c.frontmatter.get("description", "")[:50]
|
|
274
|
+
t.add_row(c.type, c.name, desc)
|
|
275
|
+
console.print(t)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── add ──────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
@cli.command("add")
|
|
281
|
+
@click.argument("plugin_name", required=False)
|
|
282
|
+
@click.option("--source", "source_name", default=None, help="Source name")
|
|
283
|
+
@click.option("--all", "install_all", is_flag=True, help="Install all local plugins")
|
|
284
|
+
@click.option("--only", "only_types", default=None, help="Filter: skill,agent,command,mcp")
|
|
285
|
+
@click.option("--git", "git_url", default=None, help="Install from a git URL directly")
|
|
286
|
+
@click.option("--scope", default="global", type=click.Choice(["global", "workspace"]), help="Install scope")
|
|
287
|
+
def add_cmd(plugin_name: str | None, source_name: str | None, install_all: bool, only_types: str | None, git_url: str | None, scope: str):
|
|
288
|
+
"""Convert and install plugins to ~/.kiro (global) or .kiro/ (workspace)."""
|
|
289
|
+
converter.set_scope(scope)
|
|
290
|
+
type_filter = set(only_types.split(",")) if only_types else None
|
|
291
|
+
|
|
292
|
+
if git_url:
|
|
293
|
+
s = src_mod.add_source(git_url)
|
|
294
|
+
source_name = s.name
|
|
295
|
+
install_all = True
|
|
296
|
+
|
|
297
|
+
# Auto-find source for a specific plugin name
|
|
298
|
+
if plugin_name and not source_name:
|
|
299
|
+
found = _find_plugin_source(plugin_name)
|
|
300
|
+
if found:
|
|
301
|
+
source_name, plugins = found
|
|
302
|
+
else:
|
|
303
|
+
source_name = _resolve_source(None)
|
|
304
|
+
plugins = src_mod.parse_marketplace(source_name)
|
|
305
|
+
else:
|
|
306
|
+
source_name = _resolve_source(source_name)
|
|
307
|
+
plugins = src_mod.parse_marketplace(source_name)
|
|
308
|
+
|
|
309
|
+
commit = _get_commit(source_name)
|
|
310
|
+
|
|
311
|
+
if install_all:
|
|
312
|
+
targets = [p for p in plugins if p.get("_local_path") and Path(p["_local_path"]).is_dir()]
|
|
313
|
+
elif plugin_name:
|
|
314
|
+
targets = [p for p in plugins if p["name"] == plugin_name]
|
|
315
|
+
else:
|
|
316
|
+
raise click.ClickException("Specify a plugin name or --all")
|
|
317
|
+
|
|
318
|
+
if not targets:
|
|
319
|
+
raise click.ClickException("No matching plugins found")
|
|
320
|
+
|
|
321
|
+
total_components = 0
|
|
322
|
+
for plugin in targets:
|
|
323
|
+
local_path = plugin.get("_local_path")
|
|
324
|
+
|
|
325
|
+
# Auto-fetch external plugins if not local yet
|
|
326
|
+
if (not local_path or not Path(local_path).is_dir()) and isinstance(plugin.get("source"), dict):
|
|
327
|
+
with console.status(f"Fetching {plugin['name']}..."):
|
|
328
|
+
fetched = src_mod.fetch_external_plugin(plugin["name"], plugin["source"])
|
|
329
|
+
if fetched and fetched.is_dir():
|
|
330
|
+
local_path = str(fetched)
|
|
331
|
+
else:
|
|
332
|
+
console.print(f" [yellow]⚠ Failed to fetch {plugin['name']}[/]")
|
|
333
|
+
continue
|
|
334
|
+
elif not local_path or not Path(local_path).is_dir():
|
|
335
|
+
console.print(f" [yellow]⚠ Skipping {plugin['name']} (not available)[/]")
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
components = scanner.scan_plugin(Path(local_path), plugin.get("_skill_filter"))
|
|
339
|
+
if type_filter:
|
|
340
|
+
components = [c for c in components if c.type in type_filter]
|
|
341
|
+
|
|
342
|
+
if not components:
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
converted = []
|
|
346
|
+
for comp in components:
|
|
347
|
+
conflict = converter.check_conflict(comp.name, comp.type, plugin["name"], source_name)
|
|
348
|
+
if conflict:
|
|
349
|
+
console.print(f" [yellow]⚠ Skipping {comp.type}:{comp.name} — {conflict}[/]")
|
|
350
|
+
continue
|
|
351
|
+
result = converter.convert_component(comp, plugin["name"], source_name)
|
|
352
|
+
if result:
|
|
353
|
+
converted.append(result)
|
|
354
|
+
total_components += 1
|
|
355
|
+
|
|
356
|
+
if converted:
|
|
357
|
+
registry.add_installed(plugin["name"], source_name, converted, commit)
|
|
358
|
+
types_summary = ", ".join(sorted(set(c["type"] for c in converted)))
|
|
359
|
+
console.print(f" [green]✓[/] {plugin['name']} → {len(converted)} components ({types_summary})")
|
|
360
|
+
|
|
361
|
+
target_dir = "~/.kiro/" if scope == "global" else ".kiro/"
|
|
362
|
+
console.print(f"\n[green]Done.[/] {total_components} components installed to {target_dir}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ── delete ───────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
@cli.command("delete")
|
|
368
|
+
@click.argument("plugin_name", required=False)
|
|
369
|
+
@click.option("--all", "delete_all", is_flag=True, help="Delete all installed plugins")
|
|
370
|
+
@click.confirmation_option(prompt="Are you sure?")
|
|
371
|
+
def delete_cmd(plugin_name: str | None, delete_all: bool):
|
|
372
|
+
"""Remove converted plugins from ~/.kiro."""
|
|
373
|
+
if delete_all:
|
|
374
|
+
targets = [p["plugin_name"] for p in registry.get_installed()]
|
|
375
|
+
elif plugin_name:
|
|
376
|
+
targets = [plugin_name]
|
|
377
|
+
else:
|
|
378
|
+
raise click.ClickException("Specify a plugin name or --all")
|
|
379
|
+
|
|
380
|
+
for name in targets:
|
|
381
|
+
components = registry.remove_installed(name)
|
|
382
|
+
for comp in components:
|
|
383
|
+
converter.remove_converted(comp["target_path"])
|
|
384
|
+
console.print(f" [green]✓[/] {name} removed ({len(components)} components)")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ── update ───────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
@cli.command("update")
|
|
390
|
+
@click.argument("plugin_name", required=False)
|
|
391
|
+
@click.option("--all", "update_all", is_flag=True, help="Update all installed plugins")
|
|
392
|
+
def update_cmd(plugin_name: str | None, update_all: bool):
|
|
393
|
+
"""Re-sync from source and re-convert."""
|
|
394
|
+
installed = registry.get_installed()
|
|
395
|
+
if update_all:
|
|
396
|
+
targets = installed
|
|
397
|
+
elif plugin_name:
|
|
398
|
+
targets = [p for p in installed if p["plugin_name"] == plugin_name]
|
|
399
|
+
else:
|
|
400
|
+
raise click.ClickException("Specify a plugin name or --all")
|
|
401
|
+
|
|
402
|
+
if not targets:
|
|
403
|
+
raise click.ClickException("No matching installed plugins")
|
|
404
|
+
|
|
405
|
+
# Update sources
|
|
406
|
+
updated_sources = set()
|
|
407
|
+
for p in targets:
|
|
408
|
+
sn = p["source_name"]
|
|
409
|
+
if sn not in updated_sources:
|
|
410
|
+
with console.status(f"Updating source {sn}..."):
|
|
411
|
+
src_mod.update_source(sn)
|
|
412
|
+
updated_sources.add(sn)
|
|
413
|
+
|
|
414
|
+
# Re-convert
|
|
415
|
+
for p in targets:
|
|
416
|
+
sn = p["source_name"]
|
|
417
|
+
plugins = src_mod.parse_marketplace(sn)
|
|
418
|
+
plugin = next((pl for pl in plugins if pl["name"] == p["plugin_name"]), None)
|
|
419
|
+
if not plugin or not plugin.get("_local_path"):
|
|
420
|
+
console.print(f" [yellow]⚠ {p['plugin_name']} not found in source[/]")
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# Remove old
|
|
424
|
+
for comp in p.get("components", []):
|
|
425
|
+
converter.remove_converted(comp["target_path"])
|
|
426
|
+
|
|
427
|
+
# Re-scan and convert
|
|
428
|
+
local_path = Path(plugin["_local_path"])
|
|
429
|
+
components = scanner.scan_plugin(local_path, plugin.get("_skill_filter"))
|
|
430
|
+
commit = _get_commit(sn)
|
|
431
|
+
|
|
432
|
+
converted = []
|
|
433
|
+
for comp in components:
|
|
434
|
+
result = converter.convert_component(comp, p["plugin_name"], sn)
|
|
435
|
+
if result:
|
|
436
|
+
converted.append(result)
|
|
437
|
+
|
|
438
|
+
registry.add_installed(p["plugin_name"], sn, converted, commit)
|
|
439
|
+
console.print(f" [green]✓[/] {p['plugin_name']} updated ({len(converted)} components)")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Convert Claude Code plugin components to Kiro format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .models import KIRO_HOME, REGISTRY_FILE, load_json, kiro_root
|
|
10
|
+
from .scanner import ScannedComponent
|
|
11
|
+
|
|
12
|
+
_scope = "global"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_scope(scope: str):
|
|
16
|
+
global _scope
|
|
17
|
+
_scope = scope
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _kiro_agent_path(name: str) -> Path:
|
|
21
|
+
return kiro_root(_scope) / "agents" / f"{name}.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _kiro_skill_dir(name: str, plugin_name: str, source_name: str) -> Path:
|
|
25
|
+
safe_source = source_name.replace("/", "--")
|
|
26
|
+
return kiro_root(_scope) / "skills" / f"{safe_source}--{plugin_name}--{name}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_ours(target: Path) -> bool:
|
|
30
|
+
"""Check if an existing file was created by us (has [from cc:] marker)."""
|
|
31
|
+
if not target.exists():
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
d = json.loads(target.read_text())
|
|
35
|
+
return "[from cc:" in d.get("description", "")
|
|
36
|
+
except Exception:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_our_skill(target_dir: Path) -> bool:
|
|
41
|
+
"""Check if a skill dir was installed by us (in registry)."""
|
|
42
|
+
reg = load_json(REGISTRY_FILE, {"installed": []})
|
|
43
|
+
target_str = str(target_dir)
|
|
44
|
+
for p in reg.get("installed", []):
|
|
45
|
+
for c in p.get("components", []):
|
|
46
|
+
if target_str in c.get("target_path", ""):
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_conflict(name: str, comp_type: str, plugin_name: str = "", source_name: str = "") -> str | None:
|
|
52
|
+
"""Return conflict message if target already exists and isn't ours. None if ok."""
|
|
53
|
+
if comp_type in ("agent", "command", "mcp"):
|
|
54
|
+
suffix = f"{name}-mcp" if comp_type == "mcp" else name
|
|
55
|
+
target = _kiro_agent_path(suffix)
|
|
56
|
+
if target.exists() and not _is_ours(target):
|
|
57
|
+
return f"Agent '{suffix}' already exists at {target}"
|
|
58
|
+
elif comp_type == "skill":
|
|
59
|
+
target = _kiro_skill_dir(name, plugin_name, source_name)
|
|
60
|
+
if target.is_dir() and not _is_our_skill(target):
|
|
61
|
+
return f"Skill '{name}' already exists at {target}"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _map_tools(fm: dict) -> list[str]:
|
|
66
|
+
"""Map Claude Code tool names to Kiro tool names."""
|
|
67
|
+
cc_tools = fm.get("tools", "")
|
|
68
|
+
if not cc_tools:
|
|
69
|
+
return ["*"]
|
|
70
|
+
if isinstance(cc_tools, str):
|
|
71
|
+
cc_tools = [t.strip() for t in cc_tools.split(",")]
|
|
72
|
+
|
|
73
|
+
mapping = {
|
|
74
|
+
"Read": "fs_read", "LS": "fs_read", "NotebookRead": "fs_read",
|
|
75
|
+
"Write": "fs_write", "Edit": "fs_write", "NotebookEdit": "fs_write", "TodoWrite": "fs_write",
|
|
76
|
+
"Glob": "glob", "Grep": "grep",
|
|
77
|
+
"Bash": "execute_bash", "BashOutput": "execute_bash", "KillShell": "execute_bash",
|
|
78
|
+
"Monitor": "execute_bash", "PowerShell": "execute_bash",
|
|
79
|
+
"WebFetch": "web_fetch", "WebSearch": "web_search",
|
|
80
|
+
"Agent": "use_subagent",
|
|
81
|
+
"LSP": "code",
|
|
82
|
+
}
|
|
83
|
+
kiro_tools = set()
|
|
84
|
+
for t in cc_tools:
|
|
85
|
+
base = t.split("(")[0].strip()
|
|
86
|
+
if base in mapping:
|
|
87
|
+
kiro_tools.add(mapping[base])
|
|
88
|
+
else:
|
|
89
|
+
kiro_tools.add(base.lower())
|
|
90
|
+
return sorted(kiro_tools) if kiro_tools else ["*"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def convert_agent(comp: ScannedComponent, plugin_name: str) -> dict:
|
|
94
|
+
"""Convert a Claude Code agent .md to Kiro agent .json."""
|
|
95
|
+
fm = comp.frontmatter
|
|
96
|
+
|
|
97
|
+
desc = fm.get("description", "")
|
|
98
|
+
if not desc:
|
|
99
|
+
desc = f"[from cc:{plugin_name}]"
|
|
100
|
+
else:
|
|
101
|
+
desc = f"{desc} [from cc:{plugin_name}]"
|
|
102
|
+
|
|
103
|
+
agent_config = {
|
|
104
|
+
"$schema": "https://raw.githubusercontent.com/aws/amazon-q-developer-cli/refs/heads/main/schemas/agent-v1.json",
|
|
105
|
+
"name": comp.name,
|
|
106
|
+
"description": desc,
|
|
107
|
+
"prompt": comp.body.strip(),
|
|
108
|
+
"tools": _map_tools(fm),
|
|
109
|
+
"allowedTools": [],
|
|
110
|
+
"resources": [],
|
|
111
|
+
"hooks": {},
|
|
112
|
+
"toolsSettings": {},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if fm.get("model"):
|
|
116
|
+
model_map = {"opus": "claude-opus-4", "sonnet": "claude-sonnet-4", "haiku": "claude-haiku-3.5"}
|
|
117
|
+
agent_config["model"] = model_map.get(fm["model"], fm["model"])
|
|
118
|
+
|
|
119
|
+
target = _kiro_agent_path(comp.name)
|
|
120
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
target.write_text(json.dumps(agent_config, indent=2, ensure_ascii=False) + "\n")
|
|
122
|
+
return {"type": comp.type, "name": comp.name, "source_rel": comp.rel_path, "target_path": str(target)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def convert_skill(comp: ScannedComponent, plugin_name: str, source_name: str = "") -> dict:
|
|
126
|
+
"""Convert a Claude Code skill to Kiro skill (copy SKILL.md + supporting files)."""
|
|
127
|
+
target_dir = _kiro_skill_dir(comp.name, plugin_name, source_name)
|
|
128
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
src_dir = comp.path.parent
|
|
131
|
+
for item in src_dir.iterdir():
|
|
132
|
+
dest = target_dir / item.name
|
|
133
|
+
if item.is_dir():
|
|
134
|
+
if dest.exists():
|
|
135
|
+
shutil.rmtree(dest)
|
|
136
|
+
shutil.copytree(item, dest)
|
|
137
|
+
else:
|
|
138
|
+
shutil.copy2(item, dest)
|
|
139
|
+
|
|
140
|
+
target = target_dir / "SKILL.md"
|
|
141
|
+
return {"type": "skill", "name": comp.name, "source_rel": comp.rel_path, "target_path": str(target)}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def convert_command(comp: ScannedComponent, plugin_name: str) -> dict:
|
|
145
|
+
return convert_agent(comp, plugin_name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def convert_mcp(comp: ScannedComponent, plugin_name: str) -> dict:
|
|
149
|
+
mcp_data = comp.frontmatter
|
|
150
|
+
mcp_name = f"{plugin_name}-mcp"
|
|
151
|
+
|
|
152
|
+
agent_config = {
|
|
153
|
+
"$schema": "https://raw.githubusercontent.com/aws/amazon-q-developer-cli/refs/heads/main/schemas/agent-v1.json",
|
|
154
|
+
"name": mcp_name,
|
|
155
|
+
"description": f"MCP servers [from cc:{plugin_name}]",
|
|
156
|
+
"prompt": None,
|
|
157
|
+
"mcpServers": {},
|
|
158
|
+
"tools": ["*"],
|
|
159
|
+
"allowedTools": [],
|
|
160
|
+
"resources": [],
|
|
161
|
+
"hooks": {},
|
|
162
|
+
"toolsSettings": {},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for server_name, server_config in mcp_data.items():
|
|
166
|
+
kiro_server = {}
|
|
167
|
+
stype = server_config.get("type", "")
|
|
168
|
+
if stype in ("http", "streamable-http", "sse"):
|
|
169
|
+
kiro_server["type"] = "streamable-http" if stype == "http" else stype
|
|
170
|
+
kiro_server["url"] = server_config.get("url", "")
|
|
171
|
+
elif stype == "stdio":
|
|
172
|
+
kiro_server["type"] = "stdio"
|
|
173
|
+
kiro_server["command"] = server_config.get("command", "")
|
|
174
|
+
if server_config.get("args"):
|
|
175
|
+
kiro_server["args"] = server_config["args"]
|
|
176
|
+
else:
|
|
177
|
+
kiro_server = server_config
|
|
178
|
+
agent_config["mcpServers"][server_name] = kiro_server
|
|
179
|
+
|
|
180
|
+
target = _kiro_agent_path(mcp_name)
|
|
181
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
target.write_text(json.dumps(agent_config, indent=2, ensure_ascii=False) + "\n")
|
|
183
|
+
return {"type": "mcp", "name": mcp_name, "source_rel": ".mcp.json", "target_path": str(target)}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
CONVERTERS = {
|
|
187
|
+
"skill": convert_skill,
|
|
188
|
+
"command": convert_command,
|
|
189
|
+
"agent": convert_agent,
|
|
190
|
+
"mcp": convert_mcp,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def convert_component(comp: ScannedComponent, plugin_name: str, source_name: str = "") -> dict | None:
|
|
195
|
+
converter = CONVERTERS.get(comp.type)
|
|
196
|
+
if converter:
|
|
197
|
+
if comp.type == "skill":
|
|
198
|
+
return converter(comp, plugin_name, source_name)
|
|
199
|
+
return converter(comp, plugin_name)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def remove_converted(target_path: str):
|
|
204
|
+
"""Remove a converted file/directory. For skills, remove the whole skill dir."""
|
|
205
|
+
p = Path(target_path)
|
|
206
|
+
if p.is_dir():
|
|
207
|
+
shutil.rmtree(p)
|
|
208
|
+
elif p.exists():
|
|
209
|
+
parent = p.parent
|
|
210
|
+
p.unlink()
|
|
211
|
+
# If this was a SKILL.md inside a skill dir, remove the whole dir
|
|
212
|
+
if parent.parent == KIRO_HOME / "skills":
|
|
213
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field, asdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
KIRO_HOME = Path.home() / ".kiro"
|
|
11
|
+
KIRO_WORKSPACE = Path(".kiro")
|
|
12
|
+
CONVERTER_HOME = KIRO_HOME / "cc-plugins"
|
|
13
|
+
CACHE_DIR = CONVERTER_HOME / "cache"
|
|
14
|
+
CONFIG_FILE = CONVERTER_HOME / "config.json"
|
|
15
|
+
REGISTRY_FILE = CONVERTER_HOME / "registry.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def kiro_root(scope: str = "global") -> Path:
|
|
19
|
+
return KIRO_HOME if scope == "global" else KIRO_WORKSPACE
|
|
20
|
+
|
|
21
|
+
CC_PREFIX = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Source:
|
|
26
|
+
name: str
|
|
27
|
+
url: str
|
|
28
|
+
cloned_at: str = ""
|
|
29
|
+
commit: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Component:
|
|
34
|
+
type: str # skill, command, agent, mcp
|
|
35
|
+
name: str
|
|
36
|
+
source_rel: str # relative path within plugin
|
|
37
|
+
target_path: str # absolute path of converted file
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class InstalledPlugin:
|
|
42
|
+
plugin_name: str
|
|
43
|
+
source_name: str
|
|
44
|
+
components: list[dict] = field(default_factory=list)
|
|
45
|
+
installed_at: str = ""
|
|
46
|
+
source_commit: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_json(path: Path, default=None):
|
|
50
|
+
if path.exists():
|
|
51
|
+
return json.loads(path.read_text())
|
|
52
|
+
return default if default is not None else {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_json(path: Path, data):
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Registry: track installed conversions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .models import REGISTRY_FILE, load_json, save_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load() -> dict:
|
|
12
|
+
return load_json(REGISTRY_FILE, {"installed": []})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _save(data: dict):
|
|
16
|
+
save_json(REGISTRY_FILE, data)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_installed(plugin_name: str, source_name: str, components: list[dict], commit: str):
|
|
20
|
+
reg = _load()
|
|
21
|
+
reg["installed"] = [p for p in reg["installed"] if p["plugin_name"] != plugin_name]
|
|
22
|
+
reg["installed"].append({
|
|
23
|
+
"plugin_name": plugin_name,
|
|
24
|
+
"source_name": source_name,
|
|
25
|
+
"components": components,
|
|
26
|
+
"installed_at": datetime.now().isoformat(),
|
|
27
|
+
"source_commit": commit,
|
|
28
|
+
})
|
|
29
|
+
_save(reg)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def remove_installed(plugin_name: str) -> list[dict]:
|
|
33
|
+
"""Remove and return the plugin's components."""
|
|
34
|
+
reg = _load()
|
|
35
|
+
removed = []
|
|
36
|
+
remaining = []
|
|
37
|
+
for p in reg["installed"]:
|
|
38
|
+
if p["plugin_name"] == plugin_name:
|
|
39
|
+
removed = p.get("components", [])
|
|
40
|
+
else:
|
|
41
|
+
remaining.append(p)
|
|
42
|
+
reg["installed"] = remaining
|
|
43
|
+
_save(reg)
|
|
44
|
+
return removed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_installed() -> list[dict]:
|
|
48
|
+
return _load().get("installed", [])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_installed_plugin(name: str) -> dict | None:
|
|
52
|
+
for p in get_installed():
|
|
53
|
+
if p["plugin_name"] == name:
|
|
54
|
+
return p
|
|
55
|
+
return None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Scan Claude Code plugin directories for convertible components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ScannedComponent:
|
|
12
|
+
type: str # skill, command, agent, mcp
|
|
13
|
+
name: str
|
|
14
|
+
path: Path # absolute path to the file
|
|
15
|
+
rel_path: str # relative to plugin root
|
|
16
|
+
frontmatter: dict
|
|
17
|
+
body: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
21
|
+
"""Parse YAML frontmatter from markdown."""
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
m = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
|
25
|
+
if not m:
|
|
26
|
+
return {}, text
|
|
27
|
+
try:
|
|
28
|
+
fm = yaml.safe_load(m.group(1)) or {}
|
|
29
|
+
except Exception:
|
|
30
|
+
fm = {}
|
|
31
|
+
return fm, m.group(2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scan_plugin(plugin_dir: Path, skill_filter: list[str] | None = None) -> list[ScannedComponent]:
|
|
35
|
+
"""Scan a Claude Code plugin directory for skills, commands, agents, mcp.
|
|
36
|
+
|
|
37
|
+
skill_filter: if set, only include skills whose relative dirs match (e.g. ["./skills/pdf"]).
|
|
38
|
+
"""
|
|
39
|
+
if not plugin_dir.is_dir():
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
# Normalize skill_filter to a set of absolute paths
|
|
43
|
+
allowed_skill_dirs: set[str] | None = None
|
|
44
|
+
if skill_filter:
|
|
45
|
+
allowed_skill_dirs = set()
|
|
46
|
+
for sf in skill_filter:
|
|
47
|
+
p = (plugin_dir / sf.lstrip("./")).resolve()
|
|
48
|
+
allowed_skill_dirs.add(str(p))
|
|
49
|
+
|
|
50
|
+
components = []
|
|
51
|
+
|
|
52
|
+
# Skills: skills/*/SKILL.md
|
|
53
|
+
skills_dir = plugin_dir / "skills"
|
|
54
|
+
if skills_dir.is_dir():
|
|
55
|
+
for skill_dir in skills_dir.iterdir():
|
|
56
|
+
if not skill_dir.is_dir():
|
|
57
|
+
continue
|
|
58
|
+
if allowed_skill_dirs and str(skill_dir.resolve()) not in allowed_skill_dirs:
|
|
59
|
+
continue
|
|
60
|
+
skill_md = skill_dir / "SKILL.md"
|
|
61
|
+
if skill_md.exists():
|
|
62
|
+
text = skill_md.read_text()
|
|
63
|
+
fm, body = _parse_frontmatter(text)
|
|
64
|
+
# Collect supporting files
|
|
65
|
+
components.append(ScannedComponent(
|
|
66
|
+
type="skill",
|
|
67
|
+
name=fm.get("name", skill_dir.name),
|
|
68
|
+
path=skill_md,
|
|
69
|
+
rel_path=str(skill_md.relative_to(plugin_dir)),
|
|
70
|
+
frontmatter=fm,
|
|
71
|
+
body=body,
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
# Commands: commands/*.md
|
|
75
|
+
cmds_dir = plugin_dir / "commands"
|
|
76
|
+
if cmds_dir.is_dir():
|
|
77
|
+
for md in cmds_dir.glob("*.md"):
|
|
78
|
+
text = md.read_text()
|
|
79
|
+
fm, body = _parse_frontmatter(text)
|
|
80
|
+
components.append(ScannedComponent(
|
|
81
|
+
type="command",
|
|
82
|
+
name=fm.get("name", md.stem),
|
|
83
|
+
path=md,
|
|
84
|
+
rel_path=str(md.relative_to(plugin_dir)),
|
|
85
|
+
frontmatter=fm,
|
|
86
|
+
body=body,
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
# Agents: agents/*.md
|
|
90
|
+
agents_dir = plugin_dir / "agents"
|
|
91
|
+
if agents_dir.is_dir():
|
|
92
|
+
for md in agents_dir.glob("*.md"):
|
|
93
|
+
text = md.read_text()
|
|
94
|
+
fm, body = _parse_frontmatter(text)
|
|
95
|
+
components.append(ScannedComponent(
|
|
96
|
+
type="agent",
|
|
97
|
+
name=fm.get("name", md.stem),
|
|
98
|
+
path=md,
|
|
99
|
+
rel_path=str(md.relative_to(plugin_dir)),
|
|
100
|
+
frontmatter=fm,
|
|
101
|
+
body=body,
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
# MCP: .mcp.json
|
|
105
|
+
mcp_file = plugin_dir / ".mcp.json"
|
|
106
|
+
if mcp_file.exists():
|
|
107
|
+
import json
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
mcp_data = json.loads(mcp_file.read_text())
|
|
111
|
+
components.append(ScannedComponent(
|
|
112
|
+
type="mcp",
|
|
113
|
+
name="mcp",
|
|
114
|
+
path=mcp_file,
|
|
115
|
+
rel_path=".mcp.json",
|
|
116
|
+
frontmatter=mcp_data,
|
|
117
|
+
body="",
|
|
118
|
+
))
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
return components
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Git source management and marketplace parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .models import CACHE_DIR, CONFIG_FILE, Source, load_json, save_json
|
|
12
|
+
|
|
13
|
+
def _cache_dir_for(name: str) -> Path:
|
|
14
|
+
"""Map source name (e.g. 'anthropics/skills') to a cache directory."""
|
|
15
|
+
return CACHE_DIR / name.replace("/", "--")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
EXTERNAL_CACHE = CACHE_DIR / "_external"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(cmd: list[str], cwd: str | Path | None = None) -> str:
|
|
22
|
+
r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
|
|
23
|
+
if r.returncode != 0:
|
|
24
|
+
raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{r.stderr}")
|
|
25
|
+
return r.stdout.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_commit(repo_dir: Path) -> str:
|
|
29
|
+
return _run(["git", "rev-parse", "HEAD"], cwd=repo_dir)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_source(url: str, name: str | None = None) -> Source:
|
|
33
|
+
"""Clone a marketplace/plugin repo and register it."""
|
|
34
|
+
if name is None:
|
|
35
|
+
# Extract owner/repo from URL
|
|
36
|
+
path = url.rstrip("/").rstrip(".git")
|
|
37
|
+
parts = path.split("/")
|
|
38
|
+
if len(parts) >= 2:
|
|
39
|
+
name = f"{parts[-2]}/{parts[-1]}"
|
|
40
|
+
else:
|
|
41
|
+
name = parts[-1]
|
|
42
|
+
|
|
43
|
+
repo_dir = _cache_dir_for(name)
|
|
44
|
+
if repo_dir.exists():
|
|
45
|
+
_run(["git", "pull", "--ff-only"], cwd=repo_dir)
|
|
46
|
+
else:
|
|
47
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
_run(["git", "clone", "--depth", "1", url, str(repo_dir)])
|
|
49
|
+
|
|
50
|
+
src = Source(name=name, url=url, cloned_at=datetime.now().isoformat(), commit=_get_commit(repo_dir))
|
|
51
|
+
|
|
52
|
+
config = load_json(CONFIG_FILE, {"sources": []})
|
|
53
|
+
config["sources"] = [s for s in config["sources"] if s["name"] != name]
|
|
54
|
+
config["sources"].append({"name": src.name, "url": src.url, "cloned_at": src.cloned_at, "commit": src.commit})
|
|
55
|
+
save_json(CONFIG_FILE, config)
|
|
56
|
+
return src
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def update_source(name: str) -> str:
|
|
60
|
+
"""Git pull a cached source, return new commit."""
|
|
61
|
+
repo_dir = _cache_dir_for(name)
|
|
62
|
+
if not repo_dir.exists():
|
|
63
|
+
raise FileNotFoundError(f"Source '{name}' not found in cache")
|
|
64
|
+
_run(["git", "pull", "--ff-only"], cwd=repo_dir)
|
|
65
|
+
commit = _get_commit(repo_dir)
|
|
66
|
+
|
|
67
|
+
config = load_json(CONFIG_FILE, {"sources": []})
|
|
68
|
+
for s in config["sources"]:
|
|
69
|
+
if s["name"] == name:
|
|
70
|
+
s["commit"] = commit
|
|
71
|
+
save_json(CONFIG_FILE, config)
|
|
72
|
+
return commit
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def remove_source(name: str):
|
|
76
|
+
"""Remove a cached source."""
|
|
77
|
+
repo_dir = _cache_dir_for(name)
|
|
78
|
+
if repo_dir.exists():
|
|
79
|
+
shutil.rmtree(repo_dir)
|
|
80
|
+
config = load_json(CONFIG_FILE, {"sources": []})
|
|
81
|
+
config["sources"] = [s for s in config["sources"] if s["name"] != name]
|
|
82
|
+
save_json(CONFIG_FILE, config)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_sources() -> list[dict]:
|
|
86
|
+
return load_json(CONFIG_FILE, {"sources": []}).get("sources", [])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_source_dir(name: str) -> Path:
|
|
90
|
+
d = _cache_dir_for(name)
|
|
91
|
+
if not d.exists():
|
|
92
|
+
raise FileNotFoundError(f"Source '{name}' not cached. Run: kiro-cc source add <url>")
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── external plugin fetching ─────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def _normalize_git_url(url: str) -> str:
|
|
99
|
+
"""Ensure url is a full git clone URL."""
|
|
100
|
+
if url.startswith(("http://", "https://", "git@")):
|
|
101
|
+
return url
|
|
102
|
+
# shorthand like "owner/repo" → github https
|
|
103
|
+
return f"https://github.com/{url}.git"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fetch_external_plugin(plugin_name: str, source_spec: dict) -> Path | None:
|
|
107
|
+
"""Clone an external plugin and return its local path."""
|
|
108
|
+
EXTERNAL_CACHE.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
dest = EXTERNAL_CACHE / plugin_name
|
|
110
|
+
|
|
111
|
+
src_type = source_spec.get("source", "") if isinstance(source_spec, dict) else ""
|
|
112
|
+
|
|
113
|
+
if src_type == "url":
|
|
114
|
+
url = _normalize_git_url(source_spec["url"])
|
|
115
|
+
ref = source_spec.get("ref")
|
|
116
|
+
sha = source_spec.get("sha")
|
|
117
|
+
return _clone_or_pull(dest, url, ref=ref, sha=sha)
|
|
118
|
+
|
|
119
|
+
elif src_type == "github":
|
|
120
|
+
repo = source_spec["repo"]
|
|
121
|
+
url = f"https://github.com/{repo}.git"
|
|
122
|
+
ref = source_spec.get("ref")
|
|
123
|
+
sha = source_spec.get("sha")
|
|
124
|
+
return _clone_or_pull(dest, url, ref=ref, sha=sha)
|
|
125
|
+
|
|
126
|
+
elif src_type == "git-subdir":
|
|
127
|
+
url = _normalize_git_url(source_spec["url"])
|
|
128
|
+
subpath = source_spec.get("path", "")
|
|
129
|
+
ref = source_spec.get("ref")
|
|
130
|
+
sha = source_spec.get("sha")
|
|
131
|
+
repo_dir = _clone_or_pull(dest, url, ref=ref, sha=sha)
|
|
132
|
+
if repo_dir and subpath:
|
|
133
|
+
full = repo_dir / subpath
|
|
134
|
+
return full if full.is_dir() else None
|
|
135
|
+
return repo_dir
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _clone_or_pull(dest: Path, url: str, ref: str | None = None, sha: str | None = None) -> Path | None:
|
|
141
|
+
"""Clone (or pull) a repo to dest. Optionally checkout ref/sha."""
|
|
142
|
+
try:
|
|
143
|
+
if dest.exists():
|
|
144
|
+
_run(["git", "fetch", "--depth", "1", "origin"], cwd=dest)
|
|
145
|
+
if sha:
|
|
146
|
+
_run(["git", "fetch", "--depth", "1", "origin", sha], cwd=dest)
|
|
147
|
+
_run(["git", "checkout", sha], cwd=dest)
|
|
148
|
+
elif ref:
|
|
149
|
+
_run(["git", "checkout", f"origin/{ref}"], cwd=dest)
|
|
150
|
+
else:
|
|
151
|
+
_run(["git", "pull", "--ff-only"], cwd=dest)
|
|
152
|
+
else:
|
|
153
|
+
cmd = ["git", "clone", "--depth", "1"]
|
|
154
|
+
if ref and not sha:
|
|
155
|
+
cmd += ["--branch", ref]
|
|
156
|
+
cmd += [url, str(dest)]
|
|
157
|
+
_run(cmd)
|
|
158
|
+
if sha:
|
|
159
|
+
_run(["git", "fetch", "--depth", "1", "origin", sha], cwd=dest)
|
|
160
|
+
_run(["git", "checkout", sha], cwd=dest)
|
|
161
|
+
return dest
|
|
162
|
+
except RuntimeError:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── marketplace parsing ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def parse_marketplace(source_name: str) -> list[dict]:
|
|
169
|
+
"""Parse marketplace.json and return plugin entries with resolved local paths."""
|
|
170
|
+
repo_dir = get_source_dir(source_name)
|
|
171
|
+
mp_file = repo_dir / ".claude-plugin" / "marketplace.json"
|
|
172
|
+
|
|
173
|
+
if not mp_file.exists():
|
|
174
|
+
# Treat as single plugin
|
|
175
|
+
pj = repo_dir / ".claude-plugin" / "plugin.json"
|
|
176
|
+
name = source_name
|
|
177
|
+
desc = ""
|
|
178
|
+
if pj.exists():
|
|
179
|
+
d = json.loads(pj.read_text())
|
|
180
|
+
name = d.get("name", source_name)
|
|
181
|
+
desc = d.get("description", "")
|
|
182
|
+
return [{"name": name, "description": desc, "source": ".", "_local_path": str(repo_dir)}]
|
|
183
|
+
|
|
184
|
+
mp = json.loads(mp_file.read_text())
|
|
185
|
+
plugins = []
|
|
186
|
+
for entry in mp.get("plugins", []):
|
|
187
|
+
src = entry.get("source", "")
|
|
188
|
+
local_path = None
|
|
189
|
+
|
|
190
|
+
if isinstance(src, str) and src.startswith("."):
|
|
191
|
+
local_path = str(repo_dir / src.lstrip("./"))
|
|
192
|
+
elif isinstance(src, dict):
|
|
193
|
+
# Check if already fetched
|
|
194
|
+
ext_path = _resolve_external_cached(entry["name"], src)
|
|
195
|
+
if ext_path:
|
|
196
|
+
local_path = str(ext_path)
|
|
197
|
+
|
|
198
|
+
plugins.append({
|
|
199
|
+
"name": entry.get("name", ""),
|
|
200
|
+
"description": entry.get("description", ""),
|
|
201
|
+
"category": entry.get("category", ""),
|
|
202
|
+
"source": src,
|
|
203
|
+
"_local_path": local_path,
|
|
204
|
+
"_skill_filter": entry.get("skills"), # e.g. ["./skills/pdf", "./skills/xlsx"]
|
|
205
|
+
})
|
|
206
|
+
return plugins
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_external_cached(plugin_name: str, source_spec: dict) -> Path | None:
|
|
210
|
+
"""Check if an external plugin is already cached."""
|
|
211
|
+
dest = EXTERNAL_CACHE / plugin_name
|
|
212
|
+
if not dest.is_dir():
|
|
213
|
+
return None
|
|
214
|
+
src_type = source_spec.get("source", "")
|
|
215
|
+
if src_type == "git-subdir":
|
|
216
|
+
subpath = source_spec.get("path", "")
|
|
217
|
+
if subpath:
|
|
218
|
+
full = dest / subpath
|
|
219
|
+
return full if full.is_dir() else None
|
|
220
|
+
return dest
|