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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ uv.lock
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: kiro-cc-plugins
3
+ Version: 0.1.0
4
+ Summary: Install Claude Code plugins into Kiro
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: pyyaml>=6.0
8
+ Requires-Dist: rich>=13.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