Skip to content

Commit 24b34df

Browse files
committed
feat: add agent management and refactor skills/agents to package pattern
- Add 'cam agent' command for managing Claude agents - list/fetch/view/install/uninstall agents from repos - repos/add-repo/remove-repo for managing agent sources - installed/uninstall-all for managing installed agents - Support iannuttall/claude-agents repository - Refactor skills from single file to package structure - skills/models.py - Skill, SkillRepo dataclasses - skills/base.py - BaseSkillHandler abstract class - skills/handlers.py - Claude, Codex, Gemini, Droid handlers - skills/manager.py - SkillManager orchestration - Refactor agents to package structure (same pattern) - agents/models.py - Agent, AgentRepo dataclasses - agents/base.py - BaseAgentHandler abstract class - agents/claude.py - ClaudeAgentHandler - agents/manager.py - AgentManager orchestration - All 4 features (prompts, plugins, skills, agents) now follow consistent design pattern with base class and app-specific handlers - Update tests to use new package imports and handler overrides
1 parent 81bf2b2 commit 24b34df

17 files changed

Lines changed: 2257 additions & 876 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"iannuttall/claude-agents": {
3+
"owner": "iannuttall",
4+
"name": "claude-agents",
5+
"branch": "main",
6+
"enabled": true,
7+
"agentsPath": "agents"
8+
}
9+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Agent management for Code Assistant Manager.
2+
3+
This package provides functionality to manage agents for AI coding assistants.
4+
Agents are markdown files that define custom agent behaviors and are installed to:
5+
- Claude: ~/.claude/agents/
6+
7+
Reference: https://github.com/iannuttall/claude-agents
8+
"""
9+
10+
from .base import BaseAgentHandler
11+
from .claude import ClaudeAgentHandler
12+
from .manager import VALID_APP_TYPES, AgentManager
13+
from .models import Agent, AgentRepo
14+
15+
__all__ = [
16+
"Agent",
17+
"AgentRepo",
18+
"AgentManager",
19+
"BaseAgentHandler",
20+
"ClaudeAgentHandler",
21+
"VALID_APP_TYPES",
22+
]
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""Base class for app-specific agent handlers."""
2+
3+
import io
4+
import logging
5+
import shutil
6+
import tempfile
7+
import zipfile
8+
from abc import ABC, abstractmethod
9+
from pathlib import Path
10+
from typing import Dict, List, Optional, Tuple
11+
from urllib.error import HTTPError, URLError
12+
from urllib.request import Request, urlopen
13+
14+
import yaml
15+
16+
from .models import Agent
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class BaseAgentHandler(ABC):
22+
"""Abstract base class for app-specific agent handlers.
23+
24+
Each AI tool (Claude, etc.) can have its own implementation
25+
that defines how agents are stored and managed.
26+
"""
27+
28+
def __init__(
29+
self,
30+
agents_dir_override: Optional[Path] = None,
31+
):
32+
"""Initialize the handler with optional path overrides for testing.
33+
34+
Args:
35+
agents_dir_override: Override the agents directory
36+
"""
37+
self._agents_dir_override = agents_dir_override
38+
39+
@property
40+
@abstractmethod
41+
def app_name(self) -> str:
42+
"""Return the name of the app (e.g., 'claude')."""
43+
pass
44+
45+
@property
46+
@abstractmethod
47+
def _default_agents_dir(self) -> Path:
48+
"""Return the default agents directory for this app."""
49+
pass
50+
51+
@property
52+
def agents_dir(self) -> Path:
53+
"""Return the agents directory."""
54+
if self._agents_dir_override is not None:
55+
return self._agents_dir_override
56+
return self._default_agents_dir
57+
58+
def install(self, agent: Agent) -> Path:
59+
"""Install an agent by downloading and copying to the agents directory.
60+
61+
Args:
62+
agent: The agent to install
63+
64+
Returns:
65+
Path to the installed agent file
66+
67+
Raises:
68+
ValueError: If agent has no repository information
69+
"""
70+
if not agent.repo_owner or not agent.repo_name:
71+
raise ValueError(f"Agent '{agent.key}' has no repository information")
72+
73+
# Ensure install directory exists
74+
self.agents_dir.mkdir(parents=True, exist_ok=True)
75+
76+
# Download and install
77+
temp_dir, _ = self._download_repo(
78+
agent.repo_owner, agent.repo_name, agent.repo_branch or "main"
79+
)
80+
81+
try:
82+
# Determine source path
83+
if agent.agents_path:
84+
source_path = temp_dir / agent.agents_path.strip("/") / agent.filename
85+
else:
86+
source_path = temp_dir / agent.filename
87+
88+
if not source_path.exists():
89+
raise ValueError(f"Agent file not found in repository: {source_path}")
90+
91+
# Copy to install directory
92+
dest_path = self.agents_dir / agent.filename
93+
shutil.copy2(source_path, dest_path)
94+
logger.info(f"Installed agent to: {dest_path}")
95+
return dest_path
96+
finally:
97+
if temp_dir.exists():
98+
shutil.rmtree(temp_dir)
99+
100+
def uninstall(self, agent: Agent) -> bool:
101+
"""Uninstall an agent by removing its file.
102+
103+
Args:
104+
agent: The agent to uninstall
105+
106+
Returns:
107+
True if file was removed, False if it didn't exist
108+
"""
109+
agent_file = self.agents_dir / agent.filename
110+
if agent_file.exists():
111+
agent_file.unlink()
112+
logger.info(f"Removed agent file: {agent_file}")
113+
return True
114+
return False
115+
116+
def get_installed_files(self) -> List[Path]:
117+
"""Get list of installed agent files.
118+
119+
Returns:
120+
List of paths to installed agent markdown files
121+
"""
122+
if not self.agents_dir.exists():
123+
return []
124+
return list(self.agents_dir.glob("*.md"))
125+
126+
def is_installed(self, agent: Agent) -> bool:
127+
"""Check if an agent is installed.
128+
129+
Args:
130+
agent: The agent to check
131+
132+
Returns:
133+
True if the agent file exists
134+
"""
135+
agent_file = self.agents_dir / agent.filename
136+
return agent_file.exists()
137+
138+
def parse_agent_metadata(self, agent_file: Path) -> Dict:
139+
"""Parse agent metadata from markdown file with YAML front matter.
140+
141+
Args:
142+
agent_file: Path to the agent markdown file
143+
144+
Returns:
145+
Dictionary with name, description, tools, color
146+
"""
147+
try:
148+
content = agent_file.read_text(encoding="utf-8")
149+
content = content.lstrip("\ufeff") # Remove BOM
150+
151+
parts = content.split("---", 2)
152+
if len(parts) >= 3:
153+
front_matter = parts[1].strip()
154+
155+
# Try standard YAML parsing first
156+
meta = None
157+
try:
158+
meta = yaml.safe_load(front_matter)
159+
except yaml.YAMLError:
160+
# Fall back to simple line-by-line parsing
161+
meta = self._parse_simple_yaml(front_matter)
162+
163+
if isinstance(meta, dict):
164+
# Parse tools as list
165+
tools_raw = meta.get("tools", "")
166+
if isinstance(tools_raw, str):
167+
tools = [t.strip() for t in tools_raw.split(",") if t.strip()]
168+
elif isinstance(tools_raw, list):
169+
tools = tools_raw
170+
else:
171+
tools = []
172+
173+
# Truncate long descriptions
174+
description = meta.get("description", "")
175+
if description and len(description) > 200:
176+
if ". " in description[:200]:
177+
description = description[
178+
: description.index(". ", 0, 200) + 1
179+
]
180+
else:
181+
description = description[:197] + "..."
182+
183+
return {
184+
"name": meta.get("name"),
185+
"description": description,
186+
"tools": tools,
187+
"color": meta.get("color"),
188+
}
189+
except Exception as e:
190+
logger.debug(f"Failed to read agent file: {e}")
191+
192+
return {}
193+
194+
def _parse_simple_yaml(self, content: str) -> Dict:
195+
"""Parse simple single-level YAML with key: value format.
196+
197+
Handles cases where values contain unquoted colons.
198+
199+
Args:
200+
content: YAML content string
201+
202+
Returns:
203+
Dictionary of parsed values
204+
"""
205+
result = {}
206+
for line in content.split("\n"):
207+
line = line.strip()
208+
if not line or line.startswith("#"):
209+
continue
210+
211+
colon_idx = line.find(": ")
212+
if colon_idx > 0:
213+
key = line[:colon_idx].strip()
214+
value = line[colon_idx + 2 :].strip()
215+
if (value.startswith('"') and value.endswith('"')) or (
216+
value.startswith("'") and value.endswith("'")
217+
):
218+
value = value[1:-1]
219+
result[key] = value
220+
221+
return result
222+
223+
def _download_repo(
224+
self, owner: str, name: str, branch: str = "main"
225+
) -> Tuple[Path, str]:
226+
"""Download a GitHub repository as a zip file and extract it.
227+
228+
Args:
229+
owner: Repository owner
230+
name: Repository name
231+
branch: Branch name
232+
233+
Returns:
234+
Tuple of (Path to extracted directory, actual branch name used)
235+
"""
236+
branches = [branch]
237+
if branch == "main":
238+
branches = ["main", "master"]
239+
elif branch == "master":
240+
branches = ["master", "main"]
241+
else:
242+
branches = [branch, "main", "master"]
243+
244+
for try_branch in branches:
245+
url = (
246+
f"https://github.com/{owner}/{name}/archive/refs/heads/{try_branch}.zip"
247+
)
248+
logger.debug(f"Trying to download: {url}")
249+
250+
try:
251+
req = Request(url, headers={"User-Agent": "code-assistant-manager"})
252+
with urlopen(req, timeout=60) as response:
253+
zip_data = response.read()
254+
255+
temp_dir = Path(tempfile.mkdtemp(prefix="cam-agent-"))
256+
257+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
258+
root_dir = None
259+
for name_in_zip in zf.namelist():
260+
parts = name_in_zip.split("/")
261+
if len(parts) > 1 and not root_dir:
262+
root_dir = parts[0]
263+
264+
if root_dir and name_in_zip.startswith(root_dir + "/"):
265+
rel_path = name_in_zip[len(root_dir) + 1 :]
266+
if not rel_path:
267+
continue
268+
269+
target_path = temp_dir / rel_path
270+
if name_in_zip.endswith("/"):
271+
target_path.mkdir(parents=True, exist_ok=True)
272+
else:
273+
target_path.parent.mkdir(parents=True, exist_ok=True)
274+
with (
275+
zf.open(name_in_zip) as src,
276+
open(target_path, "wb") as dst,
277+
):
278+
dst.write(src.read())
279+
280+
logger.info(f"Downloaded repository {owner}/{name}@{try_branch}")
281+
return temp_dir, try_branch
282+
283+
except HTTPError as e:
284+
if e.code == 404:
285+
logger.debug(f"Branch {try_branch} not found, trying next")
286+
continue
287+
raise
288+
except URLError as e:
289+
logger.error(f"Failed to download repository: {e}")
290+
raise
291+
292+
raise ValueError(f"Could not download repository {owner}/{name}")
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Claude-specific agent handler."""
2+
3+
from pathlib import Path
4+
5+
from .base import BaseAgentHandler
6+
7+
8+
class ClaudeAgentHandler(BaseAgentHandler):
9+
"""Agent handler for Claude Code.
10+
11+
Claude agents are markdown files stored in ~/.claude/agents/
12+
Reference: https://github.com/iannuttall/claude-agents
13+
"""
14+
15+
@property
16+
def app_name(self) -> str:
17+
return "claude"
18+
19+
@property
20+
def _default_agents_dir(self) -> Path:
21+
return Path.home() / ".claude" / "agents"

0 commit comments

Comments
 (0)