New authoring tooling and a fix for the doc-regeneration defect it exposed. Added: - src/kaizen_agentic/agent_docs.py — render + idempotent upsert of the CLAUDE.md "## Installed Agents" section (shared by installer and CLI) - `kaizen-agentic docs generate [--check]` — idempotent doc refresh / CI gate - `kaizen-agentic create-agent` — scaffold a schema-valid agent - Frontmatter schema validation in `kaizen-agentic validate` (required name/description/category, known category, valid memory/model) - tests: test_agent_docs, test_validate_schema, test_create_agent Fixed: - _update_documentation regex duplicated the Installed Agents block on every run (stopped at the first ### subheading) — now idempotent - declared frontmatter `category` is authoritative (heuristic is fallback) - list_installed_agents reads the frontmatter name, not the filename - renamed agent-project-management.md -> agent-project-assistant.md to satisfy the agent-<name>.md convention (eliminates a name/filename collision that caused install/update to write a divergent duplicate) - test_cli_error_handling no longer installs into the repo root (uses tmp) Version 1.4.0; CHANGELOG, CLI cheat sheet, agency-framework, TODO updated. Workplan KAIZEN-WP-0007 closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
558 lines
17 KiB
Python
558 lines
17 KiB
Python
"""Agent installation and management utilities."""
|
|
|
|
import shutil
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional
|
|
from dataclasses import dataclass
|
|
|
|
from .registry import AgentRegistry, AgentDefinition
|
|
from .agent_docs import (
|
|
render_installed_agents_section,
|
|
upsert_installed_agents_section,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class InstallationConfig:
|
|
"""Configuration for agent installation."""
|
|
|
|
target_dir: Path
|
|
claude_config_path: Optional[Path] = None
|
|
makefile_path: Optional[Path] = None
|
|
update_docs: bool = True
|
|
create_backup: bool = True
|
|
|
|
|
|
class AgentInstaller:
|
|
"""Handles installation and management of agents in projects."""
|
|
|
|
def __init__(self, registry: AgentRegistry):
|
|
self.registry = registry
|
|
|
|
def install_agents(
|
|
self, agent_names: List[str], config: InstallationConfig
|
|
) -> Dict[str, str]:
|
|
"""Install agents into a project.
|
|
|
|
Returns:
|
|
Dict mapping agent names to installation status
|
|
"""
|
|
results = {}
|
|
|
|
# Resolve dependencies
|
|
resolved_agents = self.registry.resolve_dependencies(agent_names)
|
|
|
|
# Create target directory if it doesn't exist
|
|
agents_dir = config.target_dir / "agents"
|
|
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create backup if requested
|
|
if config.create_backup and agents_dir.exists():
|
|
self._create_backup(agents_dir)
|
|
|
|
# Install each agent (copy by path — avoids parsing unrelated agents)
|
|
for agent_name in resolved_agents:
|
|
try:
|
|
source_path = self.registry.get_agent_path(agent_name)
|
|
if not source_path:
|
|
results[agent_name] = "ERROR: Agent not found"
|
|
continue
|
|
|
|
target_path = agents_dir / f"agent-{agent_name}.md"
|
|
shutil.copy2(source_path, target_path)
|
|
results[agent_name] = "INSTALLED"
|
|
|
|
except Exception as e:
|
|
results[agent_name] = f"ERROR: {str(e)}"
|
|
|
|
# Update configuration files
|
|
if config.claude_config_path:
|
|
self._update_claude_config(resolved_agents, config.claude_config_path)
|
|
|
|
if config.makefile_path and config.makefile_path.exists():
|
|
self._update_makefile(resolved_agents, config.makefile_path)
|
|
|
|
if config.update_docs:
|
|
self._update_documentation(resolved_agents, config.target_dir)
|
|
|
|
return results
|
|
|
|
def list_installed_agents(self, project_dir: Path) -> List[str]:
|
|
"""List agents currently installed in a project."""
|
|
agents_dir = project_dir / "agents"
|
|
if not agents_dir.exists():
|
|
return []
|
|
|
|
installed = []
|
|
for agent_file in agents_dir.glob("agent-*.md"):
|
|
# Prefer the frontmatter name (registry-authoritative); fall back to
|
|
# the filename when frontmatter is missing/unreadable. The filename
|
|
# encodes the category for a few agents (e.g. agent-project-
|
|
# management.md → name: project-assistant), so a pure filename derive
|
|
# produces names the registry cannot resolve (WP-0007 T02).
|
|
try:
|
|
agent_name = AgentDefinition._read_frontmatter(agent_file)["name"]
|
|
except Exception:
|
|
agent_name = agent_file.stem.replace("agent-", "")
|
|
installed.append(agent_name)
|
|
|
|
return sorted(installed)
|
|
|
|
def update_agents(
|
|
self, project_dir: Path, agent_names: Optional[List[str]] = None
|
|
) -> Dict[str, str]:
|
|
"""Update installed agents to latest versions."""
|
|
if agent_names is None:
|
|
agent_names = self.list_installed_agents(project_dir)
|
|
|
|
config = InstallationConfig(target_dir=project_dir)
|
|
return self.install_agents(agent_names, config)
|
|
|
|
def remove_agents(
|
|
self, agent_names: List[str], project_dir: Path
|
|
) -> Dict[str, str]:
|
|
"""Remove agents from a project."""
|
|
results = {}
|
|
agents_dir = project_dir / "agents"
|
|
|
|
for agent_name in agent_names:
|
|
try:
|
|
agent_file = agents_dir / f"agent-{agent_name}.md"
|
|
if agent_file.exists():
|
|
agent_file.unlink()
|
|
results[agent_name] = "REMOVED"
|
|
else:
|
|
results[agent_name] = "NOT_FOUND"
|
|
except Exception as e:
|
|
results[agent_name] = f"ERROR: {str(e)}"
|
|
|
|
return results
|
|
|
|
def validate_installation(self, project_dir: Path) -> Dict[str, List[str]]:
|
|
"""Validate agents installed in a project."""
|
|
errors = {}
|
|
agents_dir = project_dir / "agents"
|
|
|
|
if not agents_dir.exists():
|
|
return {"project": ["No agents directory found"]}
|
|
|
|
# Check each installed agent
|
|
for agent_file in agents_dir.glob("agent-*.md"):
|
|
agent_name = agent_file.stem.replace("agent-", "")
|
|
agent_errors = []
|
|
|
|
try:
|
|
# Check if agent exists in registry (validates the file)
|
|
agent_def = self.registry.get_agent(agent_name)
|
|
if not agent_def:
|
|
agent_errors.append("Agent not found in registry")
|
|
except Exception as e:
|
|
agent_errors.append(f"Invalid agent format: {str(e)}")
|
|
|
|
if agent_errors:
|
|
errors[agent_name] = agent_errors
|
|
|
|
return errors
|
|
|
|
def _create_backup(self, agents_dir: Path):
|
|
"""Create a backup of the existing agents directory."""
|
|
import datetime
|
|
import time
|
|
|
|
# Add microseconds to avoid collisions in tests
|
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
microseconds = int(time.time() * 1000000) % 1000000
|
|
backup_dir = agents_dir.parent / f"agents_backup_{timestamp}_{microseconds}"
|
|
|
|
# Ensure unique backup directory
|
|
counter = 0
|
|
while backup_dir.exists():
|
|
counter += 1
|
|
backup_dir = (
|
|
agents_dir.parent
|
|
/ f"agents_backup_{timestamp}_{microseconds}_{counter}"
|
|
)
|
|
|
|
shutil.copytree(agents_dir, backup_dir)
|
|
print(f"Created backup at: {backup_dir}")
|
|
|
|
def _update_claude_config(self, agent_names: List[str], config_path: Path):
|
|
"""Update Claude Code configuration with agent references."""
|
|
try:
|
|
# Read existing config
|
|
config = {}
|
|
if config_path.exists():
|
|
with open(config_path, "r") as f:
|
|
config = json.load(f)
|
|
|
|
# Ensure agents section exists
|
|
if "agents" not in config:
|
|
config["agents"] = {}
|
|
|
|
# Add agent references
|
|
for agent_name in agent_names:
|
|
config["agents"][agent_name] = {
|
|
"path": f"agents/agent-{agent_name}.md",
|
|
"enabled": True,
|
|
}
|
|
|
|
# Write updated config
|
|
with open(config_path, "w") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
print(f"Updated Claude configuration: {config_path}")
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not update Claude config: {e}")
|
|
|
|
def _update_makefile(self, agent_names: List[str], makefile_path: Path):
|
|
"""Update Makefile with agent-specific targets."""
|
|
try:
|
|
# Read existing Makefile
|
|
with open(makefile_path, "r") as f:
|
|
content = f.read()
|
|
|
|
# Add agent management targets if not present
|
|
agent_targets = """
|
|
# Agent Management Targets
|
|
agents-list:
|
|
\t@echo "Installed agents:"
|
|
\t@ls agents/ 2>/dev/null | grep agent- | sed 's/agent-//g' | sed 's/.md//g' \\
|
|
\t|| echo "No agents installed"
|
|
|
|
agents-update:
|
|
\t@echo "Updating agents..."
|
|
\t@kaizen-agentic update
|
|
|
|
agents-validate:
|
|
\t@echo "Validating agents..."
|
|
\t@kaizen-agentic validate agents/
|
|
"""
|
|
|
|
if "agents-list:" not in content:
|
|
content += agent_targets
|
|
|
|
# Write updated Makefile
|
|
with open(makefile_path, "w") as f:
|
|
f.write(content)
|
|
|
|
print(f"Updated Makefile: {makefile_path}")
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not update Makefile: {e}")
|
|
|
|
def _update_documentation(self, agent_names: List[str], project_dir: Path):
|
|
"""Update project documentation with agent information."""
|
|
try:
|
|
claude_md = project_dir / "CLAUDE.md"
|
|
|
|
agents = [
|
|
agent
|
|
for agent in (self.registry.get_agent(name) for name in agent_names)
|
|
if agent is not None
|
|
]
|
|
agent_section = render_installed_agents_section(agents)
|
|
|
|
# Update or create CLAUDE.md (idempotent upsert — WP-0007 T01/T02)
|
|
if claude_md.exists():
|
|
content = claude_md.read_text()
|
|
content = upsert_installed_agents_section(content, agent_section)
|
|
claude_md.write_text(content)
|
|
else:
|
|
header = "# Claude Code Configuration\n\n"
|
|
header += (
|
|
"This file contains Claude Code configuration and agent "
|
|
"information.\n\n"
|
|
)
|
|
claude_md.write_text(header + agent_section)
|
|
|
|
print(f"Updated documentation: {claude_md}")
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not update documentation: {e}")
|
|
|
|
|
|
class ProjectInitializer:
|
|
"""Initializes new projects with agents and templates."""
|
|
|
|
def __init__(self, registry: AgentRegistry):
|
|
self.registry = registry
|
|
|
|
def init_project(
|
|
self,
|
|
project_dir: Path,
|
|
template: str = "python-basic",
|
|
agent_names: Optional[List[str]] = None,
|
|
project_name: Optional[str] = None,
|
|
) -> Dict[str, str]:
|
|
"""Initialize a new project with agents and structure."""
|
|
results = {}
|
|
|
|
# Create project directory
|
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Get agents for template
|
|
if agent_names is None:
|
|
templates = self.registry.get_agent_templates()
|
|
agent_names = templates.get(template, templates["python-basic"])
|
|
|
|
# Set up project name
|
|
if project_name is None:
|
|
project_name = project_dir.name
|
|
|
|
# Install agents
|
|
config = InstallationConfig(
|
|
target_dir=project_dir,
|
|
claude_config_path=project_dir / "CLAUDE.md",
|
|
makefile_path=project_dir / "Makefile",
|
|
)
|
|
|
|
installer = AgentInstaller(self.registry)
|
|
install_results = installer.install_agents(agent_names, config)
|
|
results.update(install_results)
|
|
|
|
# Create basic project structure
|
|
self._create_project_structure(project_dir, project_name, template)
|
|
|
|
return results
|
|
|
|
def _create_project_structure(
|
|
self, project_dir: Path, project_name: str, template: str
|
|
):
|
|
"""Create basic project structure based on template."""
|
|
# Create directories
|
|
dirs_to_create = ["src", "tests", "docs"]
|
|
if template.startswith("python"):
|
|
dirs_to_create.extend(
|
|
[f"src/{project_name.replace('-', '_')}", ".github/workflows"]
|
|
)
|
|
|
|
for dir_name in dirs_to_create:
|
|
(project_dir / dir_name).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create basic files
|
|
self._create_gitignore(project_dir)
|
|
self._create_readme(project_dir, project_name)
|
|
self._create_makefile(project_dir, project_name)
|
|
|
|
if template.startswith("python"):
|
|
self._create_pyproject_toml(project_dir, project_name)
|
|
self._create_init_py(project_dir, project_name)
|
|
|
|
def _create_gitignore(self, project_dir: Path):
|
|
"""Create .gitignore file."""
|
|
gitignore_content = """# Python
|
|
__pycache__/
|
|
*.py[cod]
|
|
*$py.class
|
|
*.so
|
|
.Python
|
|
build/
|
|
develop-eggs/
|
|
dist/
|
|
downloads/
|
|
eggs/
|
|
.eggs/
|
|
lib/
|
|
lib64/
|
|
parts/
|
|
sdist/
|
|
var/
|
|
wheels/
|
|
*.egg-info/
|
|
.installed.cfg
|
|
*.egg
|
|
MANIFEST
|
|
|
|
# Virtual environments
|
|
.env
|
|
.venv
|
|
env/
|
|
venv/
|
|
ENV/
|
|
env.bak/
|
|
venv.bak/
|
|
|
|
# IDEs
|
|
.vscode/
|
|
.idea/
|
|
*.swp
|
|
*.swo
|
|
*~
|
|
|
|
# Testing
|
|
.pytest_cache/
|
|
.coverage
|
|
htmlcov/
|
|
.tox/
|
|
"""
|
|
(project_dir / ".gitignore").write_text(gitignore_content)
|
|
|
|
def _create_readme(self, project_dir: Path, project_name: str):
|
|
"""Create README.md file."""
|
|
readme_content = f"""# {project_name}
|
|
|
|
A Python project following best practices with Kaizen Agentic agents.
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
# Clone the repository
|
|
git clone <repository-url>
|
|
cd {project_name}
|
|
|
|
# Set up development environment
|
|
make setup-complete
|
|
|
|
# Activate virtual environment
|
|
source .venv/bin/activate
|
|
```
|
|
|
|
## Development
|
|
|
|
```bash
|
|
# Run tests
|
|
make test
|
|
|
|
# Check code quality
|
|
make lint
|
|
|
|
# Format code
|
|
make format
|
|
```
|
|
|
|
## Agents
|
|
|
|
This project uses Kaizen Agentic agents for development workflow automation.
|
|
See CLAUDE.md for agent details and usage.
|
|
"""
|
|
(project_dir / "README.md").write_text(readme_content)
|
|
|
|
def _create_pyproject_toml(self, project_dir: Path, project_name: str):
|
|
"""Create pyproject.toml file."""
|
|
pyproject_content = f"""[build-system]
|
|
requires = ["setuptools>=61.0", "wheel"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[project]
|
|
name = "{project_name}"
|
|
version = "0.1.0"
|
|
description = "A Python project with Kaizen Agentic agents"
|
|
readme = "README.md"
|
|
requires-python = ">=3.9"
|
|
license = {{text = "MIT"}}
|
|
authors = [
|
|
{{name = "Author Name", email = "author@example.com"}}
|
|
]
|
|
dependencies = []
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest>=7.0",
|
|
"black>=22.0",
|
|
"flake8>=5.0",
|
|
"mypy>=1.0",
|
|
]
|
|
|
|
# Note: Add kaizen-agentic dependency when published to PyPI
|
|
# dev = [..., "kaizen-agentic>=0.1.0"]
|
|
|
|
[tool.setuptools.packages.find]
|
|
where = ["src"]
|
|
|
|
[tool.black]
|
|
line-length = 88
|
|
target-version = ['py39']
|
|
|
|
[tool.flake8]
|
|
max-line-length = 100
|
|
exclude = [".git", "__pycache__", "build", "dist"]
|
|
|
|
[tool.mypy]
|
|
python_version = "3.9"
|
|
warn_return_any = true
|
|
warn_unused_configs = true
|
|
disallow_untyped_defs = true
|
|
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
python_files = ["test_*.py"]
|
|
python_classes = ["Test*"]
|
|
python_functions = ["test_*"]
|
|
"""
|
|
(project_dir / "pyproject.toml").write_text(pyproject_content)
|
|
|
|
def _create_init_py(self, project_dir: Path, project_name: str):
|
|
"""Create package __init__.py file."""
|
|
package_name = project_name.replace("-", "_")
|
|
init_content = f'''"""
|
|
{project_name} - A Python project with Kaizen Agentic agents.
|
|
"""
|
|
|
|
__version__ = "0.1.0"
|
|
'''
|
|
(project_dir / f"src/{package_name}/__init__.py").write_text(init_content)
|
|
|
|
def _create_makefile(self, project_dir: Path, project_name: str):
|
|
"""Create Makefile with standard targets."""
|
|
package_name = project_name.replace("-", "_")
|
|
tab = "\t"
|
|
lines = [
|
|
f"# {project_name} - Makefile for development workflow",
|
|
"# Generated by Kaizen Agentic",
|
|
"",
|
|
".PHONY: help setup-complete setup-python setup-tools test lint "
|
|
"format clean agents-status agents-update",
|
|
"",
|
|
"help:",
|
|
f'{tab}@echo "Available targets:"',
|
|
f'{tab}@echo " setup-complete - Complete development environment setup"',
|
|
f'{tab}@echo " setup-python - Set up Python virtual environment"',
|
|
f'{tab}@echo " test - Run test suite"',
|
|
f'{tab}@echo " agents-status - Show installed agents status"',
|
|
"",
|
|
"VENV := .venv",
|
|
"PYTHON := $(VENV)/bin/python",
|
|
"PIP := $(VENV)/bin/pip",
|
|
"",
|
|
"setup-complete: setup-python setup-tools",
|
|
f'{tab}@echo "Development environment setup complete"',
|
|
"",
|
|
"setup-python: $(VENV)/bin/activate",
|
|
"",
|
|
"$(VENV)/bin/activate: pyproject.toml",
|
|
f"{tab}python3 -m venv $(VENV)",
|
|
f"{tab}$(PIP) install --upgrade pip",
|
|
f'{tab}$(PIP) install -e ".[dev]"',
|
|
f"{tab}touch $(VENV)/bin/activate",
|
|
"",
|
|
"setup-tools: $(VENV)/bin/activate",
|
|
f'{tab}@echo "Development tools installed via pyproject.toml"',
|
|
"",
|
|
"test: $(VENV)/bin/activate",
|
|
f"{tab}$(PYTHON) -m pytest tests/ -v",
|
|
"",
|
|
"test-coverage: $(VENV)/bin/activate",
|
|
f"{tab}$(PYTHON) -m pytest tests/ --cov=src/{package_name} "
|
|
f"--cov-report=html --cov-report=term-missing",
|
|
"",
|
|
"lint: $(VENV)/bin/activate",
|
|
f"{tab}$(PYTHON) -m flake8 src/ tests/",
|
|
"",
|
|
"format: $(VENV)/bin/activate",
|
|
f"{tab}$(PYTHON) -m black src/ tests/",
|
|
"",
|
|
"clean:",
|
|
f"{tab}rm -rf build/ dist/ *.egg-info/ .pytest_cache/ .coverage htmlcov/",
|
|
"",
|
|
"agents-status:",
|
|
f"{tab}@command -v kaizen-agentic >/dev/null 2>&1 && kaizen-agentic status "
|
|
f'|| echo "kaizen-agentic not installed"',
|
|
"",
|
|
"agents-update:",
|
|
f"{tab}@command -v kaizen-agentic >/dev/null 2>&1 && kaizen-agentic update "
|
|
f'|| echo "kaizen-agentic not installed"',
|
|
]
|
|
(project_dir / "Makefile").write_text("\n".join(lines) + "\n")
|