Files
kaizen-agentic/src/kaizen_agentic/installer.py
tegwick 843cf4eee0
Some checks failed
ci / test (push) Failing after 40s
Publish Python package / publish (push) Successful in 4m46s
feat: agent authoring & doc generation (WP-0007, v1.4.0)
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>
2026-06-18 02:06:14 +02:00

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")