Skip to content

Commit c118666

Browse files
authored
Merge pull request #20 from zhujian0805/main
add support for codex and copilot for skills
2 parents 6013f05 + 371f0b0 commit c118666

21 files changed

Lines changed: 465 additions & 70 deletions

code_assistant_manager/cli/completion_commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ def _generate_zsh_completion() -> str:
676676
else
677677
case $words[2] in
678678
list|installed)
679-
_values 'option' '--app-type[Application type]:app:(claude codex gemini qwen codebuddy)' '--help[Show help]'
679+
_values 'option' '--app-type[Application type]:app:(claude codex copilot gemini qwen codebuddy)' '--help[Show help]'
680680
;;
681681
fetch|repos|view|delete)
682682
_values 'option' '--help[Show help]'
@@ -685,7 +685,7 @@ def _generate_zsh_completion() -> str:
685685
_values 'option' '--title[Skill title]' '--content[Skill content]' '--description[Skill description]' '--tags[Skill tags]' '--help[Show help]'
686686
;;
687687
install|uninstall|uninstall-all)
688-
_values 'option' '--app-type[Application type]:app:(claude codex gemini qwen codebuddy)' '--help[Show help]'
688+
_values 'option' '--app-type[Application type]:app:(claude codex copilot gemini qwen codebuddy)' '--help[Show help]'
689689
;;
690690
add-repo|remove-repo)
691691
_values 'option' '--owner[Repository owner]' '--repo[Repository name]' '--help[Show help]'
@@ -714,7 +714,7 @@ def _generate_zsh_completion() -> str:
714714
_values 'option' '--owner[Repository owner]' '--repo[Repository name]' '--help[Show help]'
715715
;;
716716
install|uninstall|enable|disable|validate)
717-
_values 'option' '--app[Application type]:app:(claude codebuddy)' '--help[Show help]'
717+
_values 'option' '--app[Application type]:app:(claude codebuddy codex copilot)' '--help[Show help]'
718718
;;
719719
browse|view|status)
720720
_values 'option' '--help[Show help]'

code_assistant_manager/cli/plugins/plugin_install_commands.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ def _get_handler(app_type: str = "claude") -> BasePluginHandler:
2727

2828

2929
def _check_app_cli(app_type: str = "claude"):
30-
"""Check if app CLI is available."""
30+
"""Check if app CLI is available when required by the handler."""
3131
handler = _get_handler(app_type)
32-
if not handler.get_cli_path():
32+
if getattr(handler, "uses_cli_plugin_commands", False) and not handler.get_cli_path():
3333
typer.echo(
3434
f"{Colors.RED}{app_type.capitalize()} CLI not found. Please install {app_type.capitalize()} first.{Colors.RESET}"
3535
)
@@ -371,7 +371,53 @@ def install_plugin(
371371
display_ref = f"{marketplace}:{plugin}" if marketplace else plugin
372372
typer.echo(f"{Colors.CYAN}Installing plugin: {display_ref}...{Colors.RESET}")
373373

374-
success, msg = handler.install_plugin(plugin, marketplace)
374+
if getattr(handler, "uses_cli_plugin_commands", False):
375+
success, msg = handler.install_plugin(plugin, marketplace)
376+
else:
377+
# Install directly from CAM-configured marketplace (no app CLI required)
378+
from code_assistant_manager.plugins import PluginManager
379+
from code_assistant_manager.plugins.fetch import fetch_repo_info
380+
381+
manager = PluginManager()
382+
repo = manager.get_repo(marketplace) if marketplace else None
383+
if not repo or not repo.repo_owner or not repo.repo_name:
384+
typer.echo(
385+
f"{Colors.RED}✗ Marketplace '{marketplace}' not found in CAM configuration.{Colors.RESET}"
386+
)
387+
raise typer.Exit(1)
388+
389+
info = fetch_repo_info(repo.repo_owner, repo.repo_name, repo.repo_branch or "main")
390+
if not info or not info.plugins:
391+
typer.echo(
392+
f"{Colors.RED}✗ Could not fetch plugins from marketplace '{marketplace}'.{Colors.RESET}"
393+
)
394+
raise typer.Exit(1)
395+
396+
match = next(
397+
(p for p in info.plugins if p.get("name", "").lower() == plugin.lower()),
398+
None,
399+
)
400+
plugin_path = None
401+
source = match.get("source") if isinstance(match, dict) else None
402+
if isinstance(source, str):
403+
plugin_path = source.lstrip("./")
404+
elif isinstance(source, dict):
405+
plugin_path = source.get("path") or source.get("dir")
406+
407+
if not plugin_path:
408+
plugin_path = f"plugins/{plugin}"
409+
410+
try:
411+
handler.install_from_github(
412+
repo.repo_owner,
413+
repo.repo_name,
414+
repo.repo_branch or "main",
415+
plugin_path=plugin_path,
416+
marketplace_name=marketplace,
417+
)
418+
success, msg = True, f"Plugin installed: {display_ref}"
419+
except Exception as e:
420+
success, msg = False, str(e)
375421

376422
if success:
377423
typer.echo(f"{Colors.GREEN}{msg}{Colors.RESET}")

code_assistant_manager/cli/skills_commands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
logger = logging.getLogger(__name__)
2020

2121
skill_app = typer.Typer(
22-
help="Manage skills for AI assistants (Claude, Codex, Gemini, Droid, CodeBuddy)",
22+
help="Manage skills for AI assistants (Claude, Codex, Copilot, Gemini, Droid, CodeBuddy)",
2323
no_args_is_help=True,
2424
)
2525

@@ -35,7 +35,7 @@ def list_skills(
3535
"claude",
3636
"--app",
3737
"-a",
38-
help="App type(s) to check installed status (claude, codex, gemini, all)",
38+
help="App type(s) to check installed status (claude, codex, copilot, gemini, all)",
3939
),
4040
):
4141
"""List all skills."""

code_assistant_manager/plugin_repos.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
},
5151
"claude-code-workflows": {
5252
"name": "claude-code-workflows",
53-
"description": "Production-ready workflow orchestration with 64 focused plugins, 87 specialized agents, and 44 tools - optimized for granular installation and minimal token usage",
53+
"description": "Production-ready workflow orchestration with 67 focused plugins, 99 specialized agents, and 107 skills - optimized for granular installation and minimal token usage",
5454
"enabled": true,
5555
"type": "marketplace",
5656
"repoOwner": "wshobson",

code_assistant_manager/plugins/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
This module provides functionality to manage plugins for AI coding assistants:
44
- Claude Code: ~/.claude/plugins/
5+
- Codex: ~/.codex/plugins/
6+
- Copilot: ~/.copilot/plugins/
57
68
Plugins are installed from GitHub repositories or local directories.
79
"""
@@ -10,6 +12,7 @@
1012
from .claude import ClaudePluginHandler
1113
from .codebuddy import CodebuddyPluginHandler
1214
from .codex import CodexPluginHandler
15+
from .copilot import CopilotPluginHandler
1316
from .droid import DroidPluginHandler
1417
from .fetch import (
1518
FetchedRepoInfo,
@@ -37,6 +40,7 @@
3740
# App-specific handlers
3841
"ClaudePluginHandler",
3942
"CodexPluginHandler",
43+
"CopilotPluginHandler",
4044
"GeminiPluginHandler",
4145
"DroidPluginHandler",
4246
"CodebuddyPluginHandler",

code_assistant_manager/plugins/base.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@
1919

2020

2121
class BasePluginHandler(ABC):
22+
@property
23+
def uses_cli_plugin_commands(self) -> bool:
24+
"""Whether this handler relies on an external app CLI for plugin operations."""
25+
return False
26+
27+
@property
28+
def marketplaces_dir(self) -> Path:
29+
return self.user_plugins_dir / "marketplaces"
30+
31+
@property
32+
def known_marketplaces_file(self) -> Path:
33+
return self.user_plugins_dir / "known_marketplaces.json"
2234
"""Abstract base class for app-specific plugin handlers.
2335
2436
Each AI tool (Claude, Codex, Gemini, etc.) can have its own implementation
@@ -427,3 +439,106 @@ def get_cli_path(self) -> Optional[str]:
427439
Path to CLI executable, or None if not found
428440
"""
429441
return shutil.which(self.app_name)
442+
443+
# ==================== Marketplace Operations (non-CLI fallback) ====================
444+
445+
def get_known_marketplaces(self) -> Dict[str, Any]:
446+
if not self.known_marketplaces_file.exists():
447+
return {}
448+
try:
449+
with open(self.known_marketplaces_file, "r", encoding="utf-8") as f:
450+
data = json.load(f)
451+
return data if isinstance(data, dict) else {}
452+
except Exception:
453+
return {}
454+
455+
def marketplace_add(self, source: str) -> Tuple[bool, str]:
456+
"""Record a marketplace as installed for apps without a plugin CLI."""
457+
name = None
458+
try:
459+
from .fetch import fetch_repo_info_from_url, parse_github_url
460+
461+
info = fetch_repo_info_from_url(source)
462+
if info:
463+
name = info.name
464+
if not name:
465+
parsed = parse_github_url(source)
466+
if parsed:
467+
_, repo, _ = parsed
468+
name = repo
469+
except Exception:
470+
pass
471+
472+
if not name:
473+
name = Path(source).name or "marketplace"
474+
475+
known = self.get_known_marketplaces()
476+
known[name] = {"source": {"url": source}}
477+
self.known_marketplaces_file.parent.mkdir(parents=True, exist_ok=True)
478+
with open(self.known_marketplaces_file, "w", encoding="utf-8") as f:
479+
json.dump(known, f, indent=2)
480+
return True, f"Marketplace added: {name}"
481+
482+
def marketplace_remove(self, name: str) -> Tuple[bool, str]:
483+
known = self.get_known_marketplaces()
484+
if name not in known:
485+
return False, f"Marketplace not found: {name}"
486+
del known[name]
487+
with open(self.known_marketplaces_file, "w", encoding="utf-8") as f:
488+
json.dump(known, f, indent=2)
489+
return True, f"Marketplace removed: {name}"
490+
491+
def marketplace_list(self) -> Tuple[bool, str]:
492+
known = self.get_known_marketplaces()
493+
lines = []
494+
for i, name in enumerate(sorted(known.keys()), 1):
495+
lines.append(f"{i}. ✓ {name}")
496+
url = known[name].get("source", {}).get("url", "")
497+
if url:
498+
lines.append(f" Source: {url}")
499+
return True, "\n".join(lines)
500+
501+
def marketplace_update(self, name: Optional[str] = None) -> Tuple[bool, str]:
502+
if name:
503+
return True, f"Marketplace '{name}' updated"
504+
return True, "Updated all marketplaces"
505+
506+
# ==================== Plugin Operations (non-CLI fallback) ====================
507+
508+
def install_plugin(self, plugin: str, marketplace: Optional[str] = None) -> Tuple[bool, str]:
509+
return False, "Plugin install via app CLI not supported for this app; use CAM-managed marketplace install"
510+
511+
def uninstall_plugin(self, plugin: str) -> Tuple[bool, str]:
512+
removed = self.uninstall(plugin)
513+
if removed:
514+
return True, f"Plugin uninstalled: {plugin}"
515+
return False, f"Plugin not found: {plugin}"
516+
517+
def enable_plugin(self, plugin: str) -> Tuple[bool, str]:
518+
self.update_settings(Plugin(name=plugin), enabled=True)
519+
return True, f"Plugin enabled: {plugin}"
520+
521+
def disable_plugin(self, plugin: str) -> Tuple[bool, str]:
522+
self.update_settings(Plugin(name=plugin), enabled=False)
523+
return True, f"Plugin disabled: {plugin}"
524+
525+
def validate_plugin(self, path: str) -> Tuple[bool, str]:
526+
p = Path(path).expanduser()
527+
ok, _ = self.validate_plugin_structure(p)
528+
return (True, "Plugin is valid") if ok else (False, "Validation failed")
529+
530+
def get_enabled_plugins(self) -> Dict[str, bool]:
531+
"""Get enabled plugins from settings.
532+
533+
Returns:
534+
Dict of plugin key -> enabled status
535+
"""
536+
if not self.settings_file.exists():
537+
return {}
538+
try:
539+
with open(self.settings_file, "r", encoding="utf-8") as f:
540+
settings = json.load(f)
541+
enabled = settings.get("enabledPlugins", {})
542+
return enabled if isinstance(enabled, dict) else {}
543+
except Exception:
544+
return {}

code_assistant_manager/plugins/claude.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414

1515
class ClaudePluginHandler(BasePluginHandler):
16+
@property
17+
def uses_cli_plugin_commands(self) -> bool:
18+
return True
19+
1620
"""Plugin handler for Claude Code.
1721
1822
Uses the `claude` CLI to manage plugins and marketplaces.

code_assistant_manager/plugins/codebuddy.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414

1515
class CodebuddyPluginHandler(BasePluginHandler):
16+
@property
17+
def uses_cli_plugin_commands(self) -> bool:
18+
return True
19+
1620
"""Plugin handler for CodeBuddy CLI.
1721
1822
Uses the `codebuddy` CLI to manage plugins and marketplaces.

code_assistant_manager/plugins/codex.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def _default_settings_file(self) -> Path:
2626

2727
@property
2828
def plugin_manifest_path(self) -> str:
29-
return ".codex-plugin/plugin.json"
29+
# Most community marketplaces today use Claude plugin manifests
30+
return ".claude-plugin/plugin.json"
3031

3132
@property
3233
def manifest_name_field(self) -> str:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Copilot plugin handler."""
2+
3+
from pathlib import Path
4+
5+
from .base import BasePluginHandler
6+
7+
8+
class CopilotPluginHandler(BasePluginHandler):
9+
"""Plugin handler for GitHub Copilot CLI.
10+
11+
Copilot CLI does not currently provide a native `plugin` subcommand like Claude.
12+
We support plugin installation by copying plugin directories into:
13+
~/.copilot/plugins/
14+
and tracking enabled state in ~/.copilot/settings.json.
15+
"""
16+
17+
@property
18+
def app_name(self) -> str:
19+
return "copilot"
20+
21+
@property
22+
def _default_home_dir(self) -> Path:
23+
return Path.home() / ".copilot"
24+
25+
@property
26+
def _default_user_plugins_dir(self) -> Path:
27+
return self._default_home_dir / "plugins"
28+
29+
@property
30+
def _default_settings_file(self) -> Path:
31+
return self._default_home_dir / "settings.json"
32+
33+
@property
34+
def plugin_manifest_path(self) -> str:
35+
# Most community marketplaces today use Claude plugin manifests
36+
return ".claude-plugin/plugin.json"
37+
38+
@property
39+
def manifest_name_field(self) -> str:
40+
return "name"

0 commit comments

Comments
 (0)