diff --git a/apps/agent-zero/configmaps-bluejay.yaml b/apps/agent-zero/configmaps-bluejay.yaml
new file mode 100644
index 0000000..fe7ffa3
--- /dev/null
+++ b/apps/agent-zero/configmaps-bluejay.yaml
@@ -0,0 +1,15705 @@
+# Blue Jay Profile ConfigMaps for Agent Zero NUC
+# Generated 2026-04-08 — source: scripts/agent-zero/
+
+---
+apiVersion: v1
+data:
+ claude_api.py: |
+ # Claude API Tool for Agent Zero
+ # Sends prompts directly to the Anthropic Claude API for tasks where local
+ # Ollama models are insufficient (complex reasoning, long-context analysis,
+ # code review of large files).
+ #
+ # Requires ANTHROPIC_API_KEY environment variable.
+ # Uses urllib (no pip dependencies) to call the Anthropic Messages API directly.
+ #
+ # Cost awareness:
+ # - claude-haiku-4-5-20251001: $0.80/$4.00 per 1M tokens (fast, cheap)
+ # - claude-sonnet-4-5-20250929: $3.00/$15.00 per 1M tokens (balanced)
+ # - claude-opus-4-6: $15.00/$75.00 per 1M tokens (most capable)
+ # Default: haiku (cheapest). Use sonnet/opus only when needed.
+
+ import json
+ import os
+ import urllib.request
+ import urllib.error
+
+ from python.helpers.tool import Tool, Response
+
+
+ _MODELS = {
+ "haiku": "claude-haiku-4-5-20251001",
+ "sonnet": "claude-sonnet-4-5-20250929",
+ "opus": "claude-opus-4-6",
+ }
+
+ _MODEL_COSTS = {
+ "haiku": {"input": 0.80, "output": 4.00},
+ "sonnet": {"input": 3.00, "output": 15.00},
+ "opus": {"input": 15.00, "output": 75.00},
+ }
+
+
+ class ClaudeApi(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Send a prompt to the Anthropic Claude API and return the response.
+
+ Use this tool when local Ollama models are insufficient for a task —
+ complex reasoning, large code review, nuanced analysis, or when you
+ need a second opinion from a more capable model.
+
+ Args (via self.args):
+ prompt (str): The prompt to send to Claude. Required.
+ model (str): Model tier — "haiku" (default, cheapest), "sonnet",
+ or "opus" (most capable, expensive).
+ system (str): System prompt for context. Optional.
+ max_tokens (int): Maximum response tokens. Default: 4096.
+ temperature (float): Temperature (0-1). Default: 0.
+ action (str): Alternative to prompt — special actions:
+ "status" — check API key and available models
+ "cost" — show model pricing
+
+ Returns:
+ Response with Claude's answer, token usage, and cost estimate.
+ """
+ action = self.args.get("action", "")
+ prompt = self.args.get("prompt", "")
+ model_tier = self.args.get("model", "haiku").lower()
+ system = self.args.get("system", "")
+ max_tokens = int(self.args.get("max_tokens", 4096))
+ temperature = float(self.args.get("temperature", 0))
+
+ # Special actions
+ if action == "status":
+ return Response(message=_check_status(), break_loop=False)
+ if action == "cost":
+ return Response(message=_show_costs(), break_loop=False)
+
+ if not prompt:
+ return Response(message=_show_usage(), break_loop=False)
+
+ # Resolve model
+ model_id = _MODELS.get(model_tier)
+ if not model_id:
+ return Response(
+ message=f"Unknown model tier '{model_tier}'. Use: haiku, sonnet, or opus.",
+ break_loop=False,
+ )
+
+ # API key
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
+ if not api_key:
+ return Response(
+ message="Error: ANTHROPIC_API_KEY is not set.\n\n"
+ "Set it as a container env var or in `/a0/usr/secrets.env`.\n"
+ "Get a key at https://console.anthropic.com/",
+ break_loop=False,
+ )
+
+ # Build request
+ messages = [{"role": "user", "content": prompt}]
+ body = {
+ "model": model_id,
+ "max_tokens": max_tokens,
+ "messages": messages,
+ "temperature": temperature,
+ }
+ if system:
+ body["system"] = system
+
+ # Call Anthropic Messages API
+ try:
+ req = urllib.request.Request(
+ "https://api.anthropic.com/v1/messages",
+ data=json.dumps(body).encode("utf-8"),
+ headers={
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ },
+ method="POST",
+ )
+
+ with urllib.request.urlopen(req, timeout=120) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+
+ except urllib.error.HTTPError as e:
+ err = e.read().decode("utf-8", errors="replace")[:500] if e.fp else ""
+ if e.code == 401:
+ return Response(
+ message="Error: Invalid API key. Check ANTHROPIC_API_KEY.",
+ break_loop=False,
+ )
+ if e.code == 429:
+ return Response(
+ message="Error: Rate limited. Wait a moment and try again.",
+ break_loop=False,
+ )
+ return Response(
+ message=f"API Error: HTTP {e.code} {e.reason}\n\n{err}",
+ break_loop=False,
+ )
+ except urllib.error.URLError as e:
+ return Response(
+ message=f"Error: Cannot reach Anthropic API — {e.reason}",
+ break_loop=False,
+ )
+ except TimeoutError:
+ return Response(
+ message="Error: Request timed out after 120 seconds.",
+ break_loop=False,
+ )
+
+ # Extract response
+ content_blocks = data.get("content", [])
+ text = "\n".join(
+ block.get("text", "")
+ for block in content_blocks
+ if block.get("type") == "text"
+ )
+
+ # Token usage and cost
+ usage = data.get("usage", {})
+ input_tokens = usage.get("input_tokens", 0)
+ output_tokens = usage.get("output_tokens", 0)
+ costs = _MODEL_COSTS.get(model_tier, {"input": 0, "output": 0})
+ cost = (input_tokens * costs["input"] + output_tokens * costs["output"]) / 1_000_000
+
+ # Format result
+ lines = [
+ f"## Claude Response ({model_tier})",
+ "",
+ text,
+ "",
+ "---",
+ f"*Model: {model_id} | Tokens: {input_tokens} in / {output_tokens} out | "
+ f"Cost: ${cost:.4f}*",
+ ]
+
+ stop = data.get("stop_reason", "")
+ if stop == "max_tokens":
+ lines.append("*Warning: Response truncated at max_tokens limit.*")
+
+ return Response(message="\n".join(lines), break_loop=False)
+
+
+ def _check_status() -> str:
+ """Check API key status."""
+ key = os.environ.get("ANTHROPIC_API_KEY", "")
+ if not key:
+ return "## Claude API Status\n\n- **API Key**: NOT SET\n- Set `ANTHROPIC_API_KEY` in environment"
+
+ masked = key[:10] + "..." + key[-4:] if len(key) > 14 else key[:4] + "***"
+ lines = [
+ "## Claude API Status",
+ "",
+ f"- **API Key**: {masked}",
+ f"- **Endpoint**: https://api.anthropic.com/v1/messages",
+ "",
+ "### Available Models",
+ "",
+ "| Tier | Model ID | Cost (in/out per 1M) |",
+ "|------|----------|---------------------|",
+ ]
+ for tier, model_id in _MODELS.items():
+ c = _MODEL_COSTS[tier]
+ lines.append(f"| {tier} | {model_id} | ${c['input']:.2f} / ${c['output']:.2f} |")
+
+ lines.extend([
+ "",
+ "**Default**: haiku (cheapest). Use `model: sonnet` or `model: opus` for harder tasks.",
+ ])
+ return "\n".join(lines)
+
+
+ def _show_costs() -> str:
+ """Show model pricing."""
+ lines = [
+ "## Claude API Pricing",
+ "",
+ "| Tier | Input ($/1M) | Output ($/1M) | Best For |",
+ "|------|-------------|--------------|----------|",
+ "| haiku | $0.80 | $4.00 | Quick answers, simple code, summaries |",
+ "| sonnet | $3.00 | $15.00 | Complex code, analysis, multi-step reasoning |",
+ "| opus | $15.00 | $75.00 | Hardest tasks, deep analysis, critical decisions |",
+ "",
+ "**Cost examples** (approximate):",
+ "- Simple question (500 in, 200 out): haiku=$0.001, sonnet=$0.004",
+ "- Code review (5K in, 2K out): haiku=$0.012, sonnet=$0.045",
+ "- Large analysis (20K in, 4K out): haiku=$0.032, sonnet=$0.120",
+ "",
+ "**Strategy**: Start with haiku. Escalate to sonnet only if quality is insufficient.",
+ ]
+ return "\n".join(lines)
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## Claude API Tool
+
+ Send prompts to Anthropic Claude when local Ollama models aren't enough.
+
+ ### Usage
+
+ ```python
+ # Quick question (cheapest — haiku)
+ {"prompt": "Explain the repository pattern in C# with an example"}
+
+ # Code review with sonnet (better quality)
+ {"prompt": "Review this code for SOLID violations:\\n...", "model": "sonnet"}
+
+ # Complex analysis with system prompt
+ {"prompt": "Analyze the trade-offs...", "model": "sonnet", "system": "You are a .NET architect."}
+
+ # Check API status
+ {"action": "status"}
+
+ # Show pricing
+ {"action": "cost"}
+ ```
+
+ ### Models
+
+ | Tier | Speed | Quality | Cost |
+ |------|-------|---------|------|
+ | `haiku` | Fast | Good | $0.80/$4.00 per 1M tokens |
+ | `sonnet` | Medium | Great | $3.00/$15.00 per 1M tokens |
+ | `opus` | Slower | Best | $15.00/$75.00 per 1M tokens |
+
+ ### Authentication
+
+ Requires `ANTHROPIC_API_KEY` environment variable.
+ Get a key at https://console.anthropic.com/
+ """
+ dotnet_analyzer.py: |
+ # .NET Project Analyzer Tool
+ # Analyzes .NET solutions and projects: finds .csproj/.slnx files, extracts dependencies,
+ # discovers EF Core entities, counts test methods, and provides architecture insights.
+
+ import subprocess
+ import os
+ import re
+ import xml.etree.ElementTree as ET
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class DotnetAnalyzer(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Analyze .NET projects and solutions.
+
+ Args (via self.args):
+ action (str): The action to perform. Required.
+ Options: "list_solutions", "list_projects", "get_dependencies",
+ "get_entities", "count_tests", "analyze_solution",
+ "find_dbcontexts", "check_ef_migrations"
+ solution_path (str): Relative path to .slnx or directory containing solution.
+ project_path (str): Relative path to .csproj file.
+ search_path (str): Base directory to search. Default: searches all FlowerCore repos.
+ limit (int): Maximum results. Default: 30.
+
+ Returns:
+ Response with .NET project analysis formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ solution_path = self.args.get("solution_path", "")
+ project_path = self.args.get("project_path", "")
+ search_path = self.args.get("search_path", "")
+ limit = self.args.get("limit", 30)
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ base_path = Path("/a0/work/repos/FlowerCore")
+ if not base_path.exists():
+ return Response(message=f"Error: FlowerCore base path does not exist: {base_path}", break_loop=False)
+
+ # Determine search path
+ if search_path:
+ full_search_path = base_path / search_path
+ else:
+ full_search_path = base_path
+
+ if not full_search_path.exists():
+ return Response(message=f"Error: Search path does not exist: {full_search_path}", break_loop=False)
+
+ # Validate action
+ valid_actions = [
+ "list_solutions", "list_projects", "get_dependencies", "get_entities",
+ "count_tests", "analyze_solution", "find_dbcontexts", "check_ef_migrations",
+ ]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ # Execute action
+ if action == "list_solutions":
+ return Response(message=_list_solutions(full_search_path, limit), break_loop=False)
+
+ if action == "list_projects":
+ if not solution_path:
+ return Response(message="Error: solution_path is required for list_projects action", break_loop=False)
+ return Response(message=_list_projects(base_path / solution_path, limit), break_loop=False)
+
+ if action == "get_dependencies":
+ if not project_path:
+ return Response(message="Error: project_path is required for get_dependencies action", break_loop=False)
+ return Response(message=_get_dependencies(base_path / project_path), break_loop=False)
+
+ if action == "get_entities":
+ if not project_path:
+ return Response(message="Error: project_path is required for get_entities action", break_loop=False)
+ return Response(message=_get_entities(base_path / project_path, limit), break_loop=False)
+
+ if action == "count_tests":
+ if not project_path:
+ return Response(message="Error: project_path is required for count_tests action", break_loop=False)
+ return Response(message=_count_tests(base_path / project_path), break_loop=False)
+
+ if action == "analyze_solution":
+ if not solution_path:
+ return Response(message="Error: solution_path is required for analyze_solution action", break_loop=False)
+ return Response(message=_analyze_solution(base_path / solution_path, limit), break_loop=False)
+
+ if action == "find_dbcontexts":
+ return Response(message=_find_dbcontexts(full_search_path, limit), break_loop=False)
+
+ if action == "check_ef_migrations":
+ if not project_path:
+ return Response(message="Error: project_path is required for check_ef_migrations action", break_loop=False)
+ return Response(message=_check_ef_migrations(base_path / project_path), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented", break_loop=False)
+
+
+ def _list_solutions(search_path: Path, limit: int) -> str:
+ """Find all .slnx files."""
+ try:
+ result = subprocess.run(
+ ["find", str(search_path), "-type", "f", "-name", "*.slnx"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ solutions = result.stdout.strip().split("\n")
+ solutions = [s for s in solutions if s]
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: find command failed or timed out"
+
+ lines = [
+ f"## .NET Solutions ({len(solutions)})",
+ "",
+ f"**Search path**: `{search_path}`",
+ "",
+ ]
+
+ for sln in solutions[:limit]:
+ relative = sln.replace(str(search_path) + "/", "")
+ lines.append(f"- `{relative}`")
+
+ if len(solutions) > limit:
+ lines.append(f"- ... and {len(solutions) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _list_projects(solution_path: Path, limit: int) -> str:
+ """List projects in a .slnx solution."""
+ if not solution_path.exists():
+ return f"Error: Solution file not found: {solution_path}"
+
+ # If solution_path is a directory, find .slnx in it
+ if solution_path.is_dir():
+ slnx_files = list(solution_path.glob("*.slnx"))
+ if not slnx_files:
+ return f"Error: No .slnx file found in {solution_path}"
+ solution_path = slnx_files[0]
+
+ # Parse .slnx XML
+ try:
+ tree = ET.parse(solution_path)
+ root = tree.getroot()
+
+ # .slnx uses elements
+ projects = []
+ for project in root.findall(".//Project"):
+ path = project.get("Path")
+ if path:
+ projects.append(path)
+
+ except ET.ParseError as e:
+ return f"Error parsing .slnx file: {e}"
+ except IOError as e:
+ return f"Error reading .slnx file: {e}"
+
+ lines = [
+ f"## Projects in Solution",
+ "",
+ f"**Solution**: `{solution_path.name}`",
+ f"**Total projects**: {len(projects)}",
+ "",
+ ]
+
+ for proj in projects[:limit]:
+ lines.append(f"- `{proj}`")
+
+ if len(projects) > limit:
+ lines.append(f"- ... and {len(projects) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _get_dependencies(project_path: Path) -> str:
+ """Extract NuGet dependencies from a .csproj file."""
+ if not project_path.exists():
+ return f"Error: Project file not found: {project_path}"
+
+ # If project_path is a directory, find .csproj in it
+ if project_path.is_dir():
+ csproj_files = list(project_path.glob("*.csproj"))
+ if not csproj_files:
+ return f"Error: No .csproj file found in {project_path}"
+ project_path = csproj_files[0]
+
+ try:
+ tree = ET.parse(project_path)
+ root = tree.getroot()
+
+ # Find PackageReference elements
+ packages = []
+ for package in root.findall(".//PackageReference"):
+ name = package.get("Include")
+ version = package.get("Version")
+ if name:
+ packages.append({"name": name, "version": version or "unspecified"})
+
+ # Find ProjectReference elements
+ project_refs = []
+ for proj_ref in root.findall(".//ProjectReference"):
+ include = proj_ref.get("Include")
+ if include:
+ project_refs.append(include)
+
+ except ET.ParseError as e:
+ return f"Error parsing .csproj file: {e}"
+ except IOError as e:
+ return f"Error reading .csproj file: {e}"
+
+ lines = [
+ f"## Project Dependencies",
+ "",
+ f"**Project**: `{project_path.name}`",
+ "",
+ ]
+
+ if packages:
+ lines.append(f"### NuGet Packages ({len(packages)})")
+ lines.append("")
+ for pkg in packages:
+ lines.append(f"- `{pkg['name']}` (v{pkg['version']})")
+ lines.append("")
+
+ if project_refs:
+ lines.append(f"### Project References ({len(project_refs)})")
+ lines.append("")
+ for ref in project_refs:
+ lines.append(f"- `{ref}`")
+ lines.append("")
+
+ if not packages and not project_refs:
+ lines.append("No dependencies found.")
+
+ return "\n".join(lines)
+
+
+ def _get_entities(project_path: Path, limit: int) -> str:
+ """Find EF Core entity classes in a project."""
+ if not project_path.exists():
+ return f"Error: Project path not found: {project_path}"
+
+ # If project_path is a file, use its directory
+ if project_path.is_file():
+ project_path = project_path.parent
+
+ # Search for C# files containing entity definitions
+ # Heuristic: look for "public class X" in files within "Entities" or "Models" directories
+ entity_pattern = re.compile(r"^\s*public\s+class\s+(\w+)", re.MULTILINE)
+
+ entities = []
+
+ try:
+ # Use find to locate .cs files
+ result = subprocess.run(
+ ["find", str(project_path), "-type", "f", "-name", "*.cs"],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ cs_files = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: find command failed"
+
+ for cs_file in cs_files:
+ if not cs_file:
+ continue
+
+ # Prioritize files in "Entities" or "Models" directories
+ if "Entities" not in cs_file and "Models" not in cs_file:
+ continue
+
+ try:
+ with open(cs_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ continue
+
+ # Find class definitions
+ matches = entity_pattern.findall(content)
+ for class_name in matches:
+ entities.append({
+ "name": class_name,
+ "file": cs_file.replace(str(project_path) + "/", ""),
+ })
+
+ lines = [
+ f"## EF Core Entities",
+ "",
+ f"**Project**: `{project_path.name}`",
+ f"**Total entities found**: {len(entities)}",
+ "",
+ ]
+
+ for entity in entities[:limit]:
+ lines.append(f"- `{entity['name']}` in `{entity['file']}`")
+
+ if len(entities) > limit:
+ lines.append(f"- ... and {len(entities) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _count_tests(project_path: Path) -> str:
+ """Count xUnit test methods in a project."""
+ if not project_path.exists():
+ return f"Error: Project path not found: {project_path}"
+
+ # If project_path is a file, use its directory
+ if project_path.is_file():
+ project_path = project_path.parent
+
+ # Search for [Fact] and [Theory] attributes
+ fact_count = 0
+ theory_count = 0
+
+ try:
+ # Use rg for fast search
+ result = subprocess.run(
+ ["rg", "-c", r"\[Fact\]", "--type", "cs", str(project_path)],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ for line in result.stdout.strip().split("\n"):
+ if ":" in line:
+ count = int(line.split(":")[-1])
+ fact_count += count
+
+ result = subprocess.run(
+ ["rg", "-c", r"\[Theory\]", "--type", "cs", str(project_path)],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ for line in result.stdout.strip().split("\n"):
+ if ":" in line:
+ count = int(line.split(":")[-1])
+ theory_count += count
+
+ except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
+ return "Error: ripgrep search failed"
+
+ total = fact_count + theory_count
+
+ lines = [
+ f"## Test Count",
+ "",
+ f"**Project**: `{project_path.name}`",
+ "",
+ f"- **[Fact] tests**: {fact_count}",
+ f"- **[Theory] tests**: {theory_count}",
+ f"- **Total test methods**: {total}",
+ ]
+
+ return "\n".join(lines)
+
+
+ def _analyze_solution(solution_path: Path, limit: int) -> str:
+ """Comprehensive solution analysis."""
+ projects_output = _list_projects(solution_path, limit)
+
+ lines = [
+ f"## Solution Analysis",
+ "",
+ projects_output,
+ ]
+
+ return "\n".join(lines)
+
+
+ def _find_dbcontexts(search_path: Path, limit: int) -> str:
+ """Find all DbContext subclasses in the codebase."""
+ try:
+ result = subprocess.run(
+ ["rg", "-n", r"class\s+\w+\s*:\s*DbContext", "--type", "cs", str(search_path)],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ matches = result.stdout.strip().split("\n")
+ matches = [m for m in matches if m]
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: ripgrep search failed"
+
+ lines = [
+ f"## DbContext Subclasses ({len(matches)})",
+ "",
+ f"**Search path**: `{search_path}`",
+ "",
+ ]
+
+ for match in matches[:limit]:
+ # Format: /path/to/file.cs:123:class MyDbContext : DbContext
+ parts = match.split(":")
+ if len(parts) >= 3:
+ file_path = parts[0].replace(str(search_path) + "/", "")
+ line_num = parts[1]
+ code = ":".join(parts[2:]).strip()
+ lines.append(f"- `{file_path}:{line_num}` - `{code}`")
+
+ if len(matches) > limit:
+ lines.append(f"- ... and {len(matches) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _check_ef_migrations(project_path: Path) -> str:
+ """Check for EF Core migrations in a project."""
+ if not project_path.exists():
+ return f"Error: Project path not found: {project_path}"
+
+ # If project_path is a file, use its directory
+ if project_path.is_file():
+ project_path = project_path.parent
+
+ migrations_path = project_path / "Migrations"
+
+ if not migrations_path.exists():
+ return f"## EF Core Migrations\n\n**Project**: `{project_path.name}`\n\nNo Migrations directory found."
+
+ try:
+ migration_files = list(migrations_path.glob("*.cs"))
+ migration_files = [f for f in migration_files if not f.name.endswith("Designer.cs")]
+ except Exception as e:
+ return f"Error scanning migrations: {e}"
+
+ lines = [
+ f"## EF Core Migrations",
+ "",
+ f"**Project**: `{project_path.name}`",
+ f"**Total migrations**: {len(migration_files)}",
+ "",
+ ]
+
+ for mig in sorted(migration_files):
+ lines.append(f"- `{mig.name}`")
+
+ return "\n".join(lines)
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## .NET Project Analyzer Usage
+
+ Analyzes .NET solutions and projects: dependencies, entities, tests, and more.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `list_solutions` | Find all .slnx files | Optional: `search_path` |
+ | `list_projects` | List projects in a solution | `solution_path` |
+ | `get_dependencies` | Extract NuGet packages and project refs | `project_path` |
+ | `get_entities` | Find EF Core entity classes | `project_path` |
+ | `count_tests` | Count [Fact] and [Theory] tests | `project_path` |
+ | `analyze_solution` | Comprehensive solution analysis | `solution_path` |
+ | `find_dbcontexts` | Find all DbContext subclasses | Optional: `search_path` |
+ | `check_ef_migrations` | Check for EF migrations | `project_path` |
+
+ ### Examples
+
+ ```python
+ # List all solutions
+ {"action": "list_solutions"}
+
+ # List projects in Signage solution
+ {"action": "list_projects", "solution_path": "FlowerCore.Signage/FlowerCore.Signage.slnx"}
+
+ # Get dependencies for Signage.Web
+ {"action": "get_dependencies", "project_path": "FlowerCore.Signage/src/FlowerCore.Signage.Web"}
+
+ # Count tests in Signage.Tests
+ {"action": "count_tests", "project_path": "FlowerCore.Signage/tests/FlowerCore.Signage.Tests"}
+
+ # Find all DbContext classes
+ {"action": "find_dbcontexts"}
+
+ # Check migrations for MySQL.Web
+ {"action": "check_ef_migrations", "project_path": "FlowerCore.MySQL/src/FlowerCore.MySQL.Web"}
+ ```
+
+ ### Path Notes
+
+ All paths are relative to `/a0/work/repos/FlowerCore/`.
+ Project paths can point to either a .csproj file or its containing directory.
+ """
+ flowercore_assist.py: |
+ # FlowerCore Development Assistant Tool
+ # High-level project intelligence for Blue Jay. Aggregates data from MEMORY.md,
+ # feature backlog, git repos, and source code to provide actionable insights:
+ # project dashboard, next-task recommendations, test gap analysis, debug tips,
+ # sprint report generation, and quick reference for conventions.
+ #
+ # This is a meta-tool — it reads project state and provides guidance.
+ # For builds use flowercore_build, for tests use flowercore_test,
+ # for search use flowercore_search, for docs use notes_query.
+
+ import os
+ import re
+ import subprocess
+ from pathlib import Path
+ from datetime import datetime
+
+ from python.helpers.tool import Tool, Response
+
+
+ # --- Paths ---
+
+ _NOTES_ROOT = Path("/a0/work/repos/FlowerCore/FlowerCore.Notes")
+ _REPOS_ROOT = Path("/a0/work/repos/FlowerCore")
+ _MEMORY_PATHS = [
+ Path("/home/stoltz/.claude/projects/-mnt-d-git-FlowerCore-FlowerCore-Notes/memory/MEMORY.md"),
+ _NOTES_ROOT / "MEMORY.md",
+ ]
+
+ # --- Service Registry ---
+
+ _SERVICES = {
+ "signage-web": {
+ "label": "Signage Web",
+ "repo": "FlowerCore.Signage",
+ "path": "src/FlowerCore.Signage.Web",
+ "test_path": "tests/FlowerCore.Signage.Web.Tests",
+ "port": 5190,
+ "grpc_port": 5191,
+ "db": "SQLite (signage.db)",
+ "stack": "Blazor Server + REST API + gRPC + MCP",
+ },
+ "signage-wpf": {
+ "label": "Signage WPF",
+ "repo": "FlowerCore.Signage.Player.Wpf",
+ "path": "src/FlowerCore.Signage.Player.Wpf",
+ "test_path": "tests/FlowerCore.Signage.Player.Wpf.Tests",
+ "port": None,
+ "db": "SQLite (%APPDATA%/.../player.db)",
+ "stack": "WPF + LibVLC + WebView2",
+ },
+ "common": {
+ "label": "Common",
+ "repo": "FlowerCore.Common",
+ "path": "src/FlowerCore.Shared.*",
+ "test_path": "tests/",
+ "port": None,
+ "db": None,
+ "stack": "Shared libraries (Api, Data, Mcp, Feeds, UI.Components, Operator.Sdk)",
+ },
+ "mysql": {
+ "label": "MySQL",
+ "repo": "FlowerCore.MySQL",
+ "path": "src/",
+ "test_path": "tests/",
+ "port": None,
+ "db": "MySQL / MSSQL / PostgreSQL",
+ "stack": "Blazor Server + REST API + MCP + KubeOps Operator",
+ },
+ "php": {
+ "label": "PHP",
+ "repo": "FlowerCore.PHP",
+ "path": "src/",
+ "test_path": "tests/",
+ "port": None,
+ "db": "MySQL",
+ "stack": "Blazor Server + REST API + MCP + KubeOps Operator",
+ },
+ }
+
+ # --- Conventions Quick Reference ---
+
+ _CONVENTIONS = {
+ "naming": {
+ "repos": "FlowerCore.{Service}.{Element}",
+ "docker": "fc-{service}-{element}:{tag}",
+ "k8s_ns": "fc-tenant-{tenantId}, operators in fc-system",
+ "crd": "flowercore.io/v1",
+ },
+ "ports": {
+ "HTTP/REST": 5190,
+ "gRPC/HTTP2": 5191,
+ "Agent Zero": 30050,
+ "Ollama": 11434,
+ },
+ "patterns": [
+ "IRepository + LocalRepository + RemoteRepository (Blooming Model)",
+ "IScreenTypeProfile — 11 profiles for display rendering",
+ "IContentRenderer — Image, Video, WebPage, Marquee, HtmlBundle",
+ "Service → Controller → MCP tool (UI/API/MCP parity)",
+ "EF Core + SQLite (dev) / MySQL (prod) — config-driven provider",
+ "Serilog structured logging — AI-parseable format",
+ "Keyboard-first UI — TabIndex, Enter submit, Escape cancel",
+ ],
+ "build": {
+ "WPF": "dotnet.exe build (Windows SDK via WSL interop)",
+ "Non-WPF": "dotnet build (Linux SDK)",
+ "Tests": "dotnet.exe test (WPF) or dotnet test (non-WPF)",
+ "Locked DLLs": "taskkill.exe /F /IM testhost.exe",
+ "Git from WSL": "git.exe (Windows git)",
+ },
+ "logs": {
+ "WPF": "%APPDATA%/FlowerCore/SignagePlayer/logs/",
+ "Web": "{project}/logs/",
+ },
+ }
+
+
+ class FlowercoreAssist(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ FlowerCore development assistant — project-aware intelligence for Blue Jay.
+
+ Provides high-level insights by aggregating project state from MEMORY.md,
+ feature backlog, git repos, and source code. Use this for guidance on
+ what to work on, understanding project state, and quick reference.
+
+ For actual building/testing/searching, use the dedicated tools:
+ flowercore_build, flowercore_test, flowercore_search, notes_query.
+
+ Args (via self.args):
+ action (str): The action to perform. Required.
+ Options:
+ "dashboard" — Project-wide status overview
+ "whats_next" — Recommend next S/M/B tasks
+ "service" — Deep dive on a specific service
+ "sprint_report" — Generate sprint summary template
+ "test_gaps" — Find untested classes/services
+ "debug_tips" — Surface relevant debugging lessons
+ "conventions" — Quick reference (naming, ports, patterns)
+ "health" — Check repo accessibility and git status
+ service (str): Service key for "service" action.
+ Keys: signage-web, signage-wpf, common, mysql, php
+ keyword (str): Search keyword for "debug_tips" action.
+ before_count (int): Test count before sprint (for sprint_report).
+ after_count (int): Test count after sprint (for sprint_report).
+ sprint_name (str): Sprint name (for sprint_report).
+
+ Returns:
+ Response with actionable project intelligence.
+ """
+ action = self.args.get("action", "")
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ actions = {
+ "dashboard": self._dashboard,
+ "whats_next": self._whats_next,
+ "service": self._service,
+ "sprint_report": self._sprint_report,
+ "test_gaps": self._test_gaps,
+ "debug_tips": self._debug_tips,
+ "conventions": self._conventions,
+ "health": self._health,
+ }
+
+ handler = actions.get(action)
+ if not handler:
+ valid = ", ".join(sorted(actions.keys()))
+ return Response(
+ message=f"Unknown action '{action}'. Valid actions: {valid}",
+ break_loop=False,
+ )
+
+ return Response(message=handler(), break_loop=False)
+
+ def _dashboard(self) -> str:
+ """Project-wide status dashboard."""
+ memory = _read_memory()
+ test_counts = _parse_test_counts(memory)
+ sprints = _parse_recent_sprints(memory, limit=5)
+ total = sum(tc["tests"] for tc in test_counts)
+
+ lines = [
+ "## FlowerCore Project Dashboard",
+ "",
+ f"**Date**: {datetime.now().strftime('%Y-%m-%d')}",
+ f"**Total Tests**: {total:,} (0 skipped, 0 failures)",
+ "",
+ "### Test Counts by Service",
+ "",
+ "| Service | Tests |",
+ "|---------|------:|",
+ ]
+
+ for tc in test_counts:
+ lines.append(f"| {tc['name']} | {tc['tests']:,} |")
+
+ lines.append(f"| **Total** | **{total:,}** |")
+
+ # Recent sprints
+ if sprints:
+ lines.extend(["", "### Recent Sprints", ""])
+ for sprint in sprints:
+ lines.append(f"- {sprint}")
+
+ # Service inventory
+ lines.extend([
+ "",
+ "### Services",
+ "",
+ "| Service | Stack | Port |",
+ "|---------|-------|-----:|",
+ ])
+ for key, svc in _SERVICES.items():
+ port = str(svc.get("port", "")) or "-"
+ lines.append(f"| {svc['label']} | {svc['stack'][:50]} | {port} |")
+
+ # Repo accessibility
+ lines.extend(["", "### Repository Status", ""])
+ for key, svc in _SERVICES.items():
+ repo_path = _REPOS_ROOT / svc["repo"]
+ exists = repo_path.exists()
+ status = "accessible" if exists else "NOT FOUND"
+ lines.append(f"- `{svc['repo']}`: {status}")
+
+ return "\n".join(lines)
+
+ def _whats_next(self) -> str:
+ """Recommend next tasks based on backlog, test gaps, and project state."""
+ memory = _read_memory()
+ backlog = _read_backlog()
+
+ # Parse backlog for open items (not DONE/COMPLETED)
+ open_items = []
+ if backlog:
+ sections = re.split(r"\n---\n", backlog)
+ for section in sections:
+ header_match = re.search(r"^##\s+(.+)$", section, re.MULTILINE)
+ if not header_match:
+ continue
+ title = header_match.group(1)
+ if "DONE" in title or "COMPLETED" in title or "MOSTLY DONE" in title:
+ continue
+ # Extract priority
+ prio_match = re.search(r"\*\*Priority:\*\*\s*(\w+)", section)
+ priority = prio_match.group(1) if prio_match else "Unknown"
+ open_items.append({"title": title.strip("~").strip(), "priority": priority})
+
+ # Identify test gaps by checking which repos have test directories
+ test_gap_services = []
+ for key, svc in _SERVICES.items():
+ repo_path = _REPOS_ROOT / svc["repo"]
+ if not repo_path.exists():
+ continue
+ # Check ratio of source to test files
+ src_count = _count_cs_files(repo_path / svc["path"]) if (repo_path / svc["path"]).exists() else 0
+ test_count = _count_cs_files(repo_path / svc["test_path"]) if (repo_path / svc["test_path"]).exists() else 0
+ if src_count > 0 and test_count < src_count * 0.5:
+ test_gap_services.append({
+ "service": svc["label"],
+ "src_files": src_count,
+ "test_files": test_count,
+ "ratio": f"{(test_count / src_count * 100):.0f}%",
+ })
+
+ lines = [
+ "## What's Next — Recommendations",
+ "",
+ ]
+
+ # S task — quick win
+ lines.append("### [S] Quick Win (5-10 min)")
+ lines.append("")
+ if test_gap_services:
+ gap = test_gap_services[0]
+ lines.append(f"- Add tests for `{gap['service']}` — current coverage: "
+ f"{gap['test_files']} test files vs {gap['src_files']} source files ({gap['ratio']})")
+ else:
+ lines.append("- Run `/cross-ref-check` to find stale doc references")
+ lines.append("")
+
+ # M task — from backlog
+ lines.append("### [M] Medium Task (15-30 min)")
+ lines.append("")
+ high_priority = [i for i in open_items if i["priority"] == "High"]
+ medium_priority = [i for i in open_items if i["priority"] == "Medium"]
+ if high_priority:
+ lines.append(f"- **{high_priority[0]['title']}** (Priority: High from feature backlog)")
+ elif medium_priority:
+ lines.append(f"- **{medium_priority[0]['title']}** (Priority: Medium from feature backlog)")
+ else:
+ lines.append("- Review and update `docs/feature-backlog.md` with current project state")
+ lines.append("")
+
+ # B task — sprint-sized
+ lines.append("### [B] Big Task (30-60 min)")
+ lines.append("")
+ if len(high_priority) > 1:
+ lines.append(f"- **{high_priority[1]['title']}** (Priority: High from feature backlog)")
+ elif open_items:
+ remaining = [i for i in open_items if i not in high_priority[:1]]
+ if remaining:
+ lines.append(f"- **{remaining[0]['title']}** (from feature backlog)")
+ else:
+ lines.append("- Run `/build-verify` — full autonomous build + test + fix loop across all services")
+ else:
+ lines.append("- Run `/build-verify` — full autonomous build + test + fix loop across all services")
+ lines.append("")
+
+ # Open backlog summary
+ if open_items:
+ lines.extend([
+ "### Open Backlog Items",
+ "",
+ "| Item | Priority |",
+ "|------|----------|",
+ ])
+ for item in open_items[:10]:
+ lines.append(f"| {item['title'][:60]} | {item['priority']} |")
+ if len(open_items) > 10:
+ lines.append(f"| ... and {len(open_items) - 10} more | |")
+
+ return "\n".join(lines)
+
+ def _service(self) -> str:
+ """Deep dive on a specific service."""
+ service_key = self.args.get("service", "")
+ if not service_key:
+ keys = ", ".join(_SERVICES.keys())
+ return f"Error: service is required. Valid keys: {keys}"
+
+ svc = _SERVICES.get(service_key)
+ if not svc:
+ keys = ", ".join(_SERVICES.keys())
+ return f"Error: Unknown service '{service_key}'. Valid keys: {keys}"
+
+ memory = _read_memory()
+ test_counts = _parse_test_counts(memory)
+
+ # Find matching test count
+ svc_tests = 0
+ svc_breakdown = ""
+ for tc in test_counts:
+ if svc["label"].lower() in tc["name"].lower():
+ svc_tests = tc["tests"]
+ svc_breakdown = tc.get("breakdown", "")
+ break
+
+ repo_path = _REPOS_ROOT / svc["repo"]
+ repo_exists = repo_path.exists()
+
+ lines = [
+ f"## Service: {svc['label']}",
+ "",
+ f"- **Repository**: `{svc['repo']}`",
+ f"- **Source**: `{svc['path']}`",
+ f"- **Tests**: `{svc['test_path']}`",
+ f"- **Stack**: {svc['stack']}",
+ ]
+
+ if svc.get("port"):
+ lines.append(f"- **Port**: {svc['port']}")
+ if svc.get("grpc_port"):
+ lines.append(f"- **gRPC Port**: {svc['grpc_port']}")
+ if svc.get("db"):
+ lines.append(f"- **Database**: {svc['db']}")
+
+ lines.extend([
+ "",
+ f"### Test Coverage",
+ f"- **Total tests**: {svc_tests:,}",
+ ])
+ if svc_breakdown:
+ lines.append(f"- **Breakdown**: {svc_breakdown[:200]}...")
+
+ lines.append(f"- **Repo accessible**: {'Yes' if repo_exists else 'No'}")
+
+ # Git info if repo exists
+ if repo_exists:
+ branch = _git_current_branch(repo_path)
+ last_commit = _git_last_commit(repo_path)
+ dirty = _git_is_dirty(repo_path)
+ lines.extend([
+ "",
+ "### Git Status",
+ f"- **Branch**: `{branch}`",
+ f"- **Last commit**: {last_commit}",
+ f"- **Working tree**: {'dirty (uncommitted changes)' if dirty else 'clean'}",
+ ])
+
+ # Extract relevant debug tips from MEMORY.md
+ debug_section = _extract_section(memory, "Key Debugging Lessons")
+ if debug_section:
+ # Filter tips relevant to this service
+ relevant_tips = _filter_debug_tips(debug_section, svc["label"])
+ if relevant_tips:
+ lines.extend(["", "### Relevant Debug Tips", ""])
+ for tip in relevant_tips[:5]:
+ lines.append(f"- {tip}")
+
+ # Recent sprints mentioning this service
+ sprint_section = _extract_section(memory, "Recent Sprints")
+ if sprint_section:
+ relevant_sprints = [
+ line.strip()
+ for line in sprint_section.split("\n")
+ if line.strip().startswith("-")
+ and any(kw.lower() in line.lower() for kw in [svc["label"], svc["repo"].split(".")[-1]])
+ ]
+ if relevant_sprints:
+ lines.extend(["", "### Recent Sprint Activity", ""])
+ for sprint in relevant_sprints[:5]:
+ lines.append(sprint)
+
+ return "\n".join(lines)
+
+ def _sprint_report(self) -> str:
+ """Generate a sprint summary template."""
+ sprint_name = self.args.get("sprint_name", "Unnamed Sprint")
+ before = self.args.get("before_count", 0)
+ after = self.args.get("after_count", 0)
+ delta = after - before if before and after else 0
+
+ memory = _read_memory()
+ test_counts = _parse_test_counts(memory)
+ total = sum(tc["tests"] for tc in test_counts)
+
+ lines = [
+ f"## Sprint Report: {sprint_name}",
+ "",
+ f"**Date**: {datetime.now().strftime('%Y-%m-%d')}",
+ "",
+ "### Test Count Delta",
+ "",
+ "| Metric | Count |",
+ "|--------|------:|",
+ ]
+
+ if before and after:
+ lines.extend([
+ f"| Before | {before:,} |",
+ f"| After | {after:,} |",
+ f"| **Delta** | **+{delta:,}** |",
+ ])
+ else:
+ lines.extend([
+ f"| Current total | {total:,} |",
+ "| Before | _(fill in)_ |",
+ "| After | _(fill in)_ |",
+ "| Delta | _(calculated)_ |",
+ ])
+
+ lines.extend([
+ "",
+ "### Current Test Counts",
+ "",
+ "| Service | Tests |",
+ "|---------|------:|",
+ ])
+ for tc in test_counts:
+ lines.append(f"| {tc['name']} | {tc['tests']:,} |")
+ lines.append(f"| **Total** | **{total:,}** |")
+
+ lines.extend([
+ "",
+ "### Changes Made",
+ "",
+ "- **Files created:** _(list)_",
+ "- **Files modified:** _(list)_",
+ "- **Build status:** Pass/Fail",
+ "",
+ "### Agent Report Template",
+ "",
+ "```",
+ "## Agent Report",
+ f"- **Files created:** []",
+ f"- **Files modified:** []",
+ f"- **Tests before:** {before or '_'} | **Tests after:** {after or '_'} | **Delta:** +{delta or '_'}",
+ f"- **Docs needing update:** []",
+ f"- **Decisions made (defaults):** []",
+ f"- **Build status:** Pass/Fail (0 warnings, 0 errors)",
+ "```",
+ ])
+
+ return "\n".join(lines)
+
+ def _test_gaps(self) -> str:
+ """Find services and classes that may lack test coverage."""
+ lines = [
+ "## Test Gap Analysis",
+ "",
+ ]
+
+ for key, svc in _SERVICES.items():
+ repo_path = _REPOS_ROOT / svc["repo"]
+ if not repo_path.exists():
+ lines.append(f"### {svc['label']} — repo not accessible")
+ lines.append("")
+ continue
+
+ lines.append(f"### {svc['label']}")
+ lines.append("")
+
+ # Find source classes (services, controllers)
+ src_classes = _find_public_classes(repo_path / svc["path"])
+ test_classes = _find_test_classes(repo_path / svc["test_path"])
+
+ # Normalize test class names: remove "Tests" suffix
+ test_targets = set()
+ for tc in test_classes:
+ name = tc.replace("Tests", "").replace("Test", "")
+ test_targets.add(name)
+
+ # Find untested classes
+ untested = []
+ for cls in src_classes:
+ name = cls["name"]
+ # Skip interfaces, DTOs, enums, configs
+ if name.startswith("I") and len(name) > 1 and name[1].isupper():
+ continue
+ if any(name.endswith(s) for s in ["Dto", "Config", "Constants", "Extensions", "Options", "Request", "Response"]):
+ continue
+ if name not in test_targets:
+ untested.append(cls)
+
+ if untested:
+ lines.append(f"**Potentially untested ({len(untested)}):**")
+ lines.append("")
+ for cls in untested[:15]:
+ lines.append(f"- `{cls['name']}` in `{cls['file']}`")
+ if len(untested) > 15:
+ lines.append(f"- ... and {len(untested) - 15} more")
+ else:
+ lines.append("All major classes appear to have tests.")
+
+ lines.extend([
+ "",
+ f"- Source classes found: {len(src_classes)}",
+ f"- Test classes found: {len(test_classes)}",
+ "",
+ ])
+
+ return "\n".join(lines)
+
+ def _debug_tips(self) -> str:
+ """Surface debugging lessons from MEMORY.md."""
+ keyword = self.args.get("keyword", "")
+ memory = _read_memory()
+ debug_section = _extract_section(memory, "Key Debugging Lessons")
+
+ if not debug_section:
+ return "## Debug Tips\n\nNo debugging lessons found in MEMORY.md."
+
+ # Parse into individual tips
+ tips = _parse_debug_tips(debug_section)
+
+ if keyword:
+ # Filter by keyword
+ filtered = [
+ t for t in tips
+ if keyword.lower() in t["title"].lower()
+ or keyword.lower() in t["body"].lower()
+ ]
+ if not filtered:
+ return f"## Debug Tips\n\nNo tips matching '{keyword}'. {len(tips)} total tips available."
+ tips = filtered
+
+ lines = [
+ f"## Debug Tips ({len(tips)} {'matching' if keyword else 'total'})",
+ "",
+ ]
+
+ if keyword:
+ lines.append(f"**Filter**: `{keyword}`")
+ lines.append("")
+
+ for tip in tips:
+ lines.append(f"### {tip['title']}")
+ lines.append("")
+ for line in tip["body"].split("\n"):
+ if line.strip():
+ lines.append(line)
+ lines.append("")
+
+ return "\n".join(lines)
+
+ def _conventions(self) -> str:
+ """Quick reference for FlowerCore conventions."""
+ lines = [
+ "## FlowerCore Conventions Quick Reference",
+ "",
+ "### Naming",
+ "",
+ "| Type | Pattern |",
+ "|------|---------|",
+ ]
+ for key, val in _CONVENTIONS["naming"].items():
+ lines.append(f"| {key} | `{val}` |")
+
+ lines.extend([
+ "",
+ "### Ports",
+ "",
+ "| Service | Port |",
+ "|---------|-----:|",
+ ])
+ for key, val in _CONVENTIONS["ports"].items():
+ lines.append(f"| {key} | {val} |")
+
+ lines.extend([
+ "",
+ "### Architecture Patterns",
+ "",
+ ])
+ for pattern in _CONVENTIONS["patterns"]:
+ lines.append(f"- {pattern}")
+
+ lines.extend([
+ "",
+ "### Build Commands",
+ "",
+ "| Target | Command |",
+ "|--------|---------|",
+ ])
+ for key, val in _CONVENTIONS["build"].items():
+ lines.append(f"| {key} | `{val}` |")
+
+ lines.extend([
+ "",
+ "### Log Locations",
+ "",
+ ])
+ for key, val in _CONVENTIONS["logs"].items():
+ lines.append(f"- **{key}**: `{val}`")
+
+ lines.extend([
+ "",
+ "### Key Design Principles",
+ "",
+ "- **Blooming Model**: Same library works Local (SQLite) and Remote (REST API)",
+ "- **Air-gap first**: No CDN, no external runtime dependencies",
+ "- **UI/API/MCP parity**: Every operation in UI also via REST + MCP",
+ "- **Database-agnostic**: SQLite dev, MySQL prod, config-driven swap",
+ "- **Keyboard-first UI**: Tab order, Enter submit, Escape cancel on every page",
+ ])
+
+ return "\n".join(lines)
+
+ def _health(self) -> str:
+ """Check repo accessibility and git status."""
+ lines = [
+ "## FlowerCore Health Check",
+ "",
+ f"**Date**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
+ "",
+ "### Repository Status",
+ "",
+ "| Repository | Exists | Branch | Clean | Last Commit |",
+ "|------------|--------|--------|-------|-------------|",
+ ]
+
+ for key, svc in _SERVICES.items():
+ repo_path = _REPOS_ROOT / svc["repo"]
+ exists = repo_path.exists()
+ if not exists:
+ lines.append(f"| {svc['repo']} | No | - | - | - |")
+ continue
+
+ branch = _git_current_branch(repo_path)
+ dirty = _git_is_dirty(repo_path)
+ last_commit = _git_last_commit(repo_path)
+ clean_str = "No" if dirty else "Yes"
+
+ # Truncate commit message
+ if len(last_commit) > 40:
+ last_commit = last_commit[:37] + "..."
+
+ lines.append(f"| {svc['repo']} | Yes | `{branch}` | {clean_str} | {last_commit} |")
+
+ # Check Notes repo separately
+ notes_path = _NOTES_ROOT
+ if notes_path.exists():
+ branch = _git_current_branch(notes_path)
+ dirty = _git_is_dirty(notes_path)
+ last_commit = _git_last_commit(notes_path)
+ if len(last_commit) > 40:
+ last_commit = last_commit[:37] + "..."
+ clean_str = "No" if dirty else "Yes"
+ lines.append(f"| FlowerCore.Notes | Yes | `{branch}` | {clean_str} | {last_commit} |")
+
+ # Check external tools
+ lines.extend(["", "### External Tools", ""])
+ tools_check = {
+ "dotnet": ["dotnet", "--version"],
+ "dotnet.exe (Windows SDK)": ["dotnet.exe", "--version"],
+ "git": ["git", "--version"],
+ "git.exe (Windows git)": ["git.exe", "--version"],
+ "rg (ripgrep)": ["rg", "--version"],
+ }
+ for tool_name, cmd in tools_check.items():
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
+ version = result.stdout.strip().split("\n")[0][:50]
+ lines.append(f"- **{tool_name}**: {version}")
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ lines.append(f"- **{tool_name}**: NOT FOUND")
+
+ # Check Ollama
+ try:
+ result = subprocess.run(
+ ["curl", "-s", "--max-time", "3", "http://localhost:11434/api/tags"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if result.returncode == 0 and result.stdout:
+ import json
+ data = json.loads(result.stdout)
+ model_count = len(data.get("models", []))
+ lines.append(f"- **Ollama**: running ({model_count} models installed)")
+ else:
+ lines.append("- **Ollama**: not responding")
+ except Exception:
+ lines.append("- **Ollama**: not reachable")
+
+ # Check Agent Zero
+ try:
+ result = subprocess.run(
+ ["curl", "-s", "--max-time", "3", "-o", "/dev/null", "-w", "%{http_code}",
+ "http://localhost:30050"],
+ capture_output=True, text=True, timeout=5,
+ )
+ status = result.stdout.strip()
+ if status in ("200", "302"):
+ lines.append(f"- **Agent Zero**: running (HTTP {status})")
+ else:
+ lines.append(f"- **Agent Zero**: responded HTTP {status}")
+ except Exception:
+ lines.append("- **Agent Zero**: not reachable")
+
+ return "\n".join(lines)
+
+
+ # --- Helpers ---
+
+ def _read_memory() -> str:
+ """Read MEMORY.md from known locations."""
+ for path in _MEMORY_PATHS:
+ try:
+ if path.exists():
+ with open(path, "r", encoding="utf-8") as f:
+ return f.read()
+ except IOError:
+ continue
+ return ""
+
+
+ def _read_backlog() -> str:
+ """Read feature-backlog.md."""
+ backlog_path = _NOTES_ROOT / "docs" / "feature-backlog.md"
+ try:
+ if backlog_path.exists():
+ with open(backlog_path, "r", encoding="utf-8") as f:
+ return f.read()
+ except IOError:
+ pass
+ return ""
+
+
+ def _parse_test_counts(memory: str) -> list:
+ """Extract test counts from MEMORY.md table."""
+ pattern = re.compile(
+ r"\|\s*Service\s*\|\s*Tests\s*\|.*?\n\|[-\s|]+\|\n(.*?)(?:\n\n|\n##|\Z)",
+ re.DOTALL,
+ )
+ match = pattern.search(memory)
+ if not match:
+ return []
+
+ results = []
+ for row in match.group(1).strip().split("\n"):
+ row_match = re.match(r"\|\s*\*?\*?([^|*]+?)\*?\*?\s*\|\s*\*?\*?([0-9,]+)\*?\*?\s*\|(.*)$", row)
+ if row_match:
+ name = row_match.group(1).strip()
+ tests = int(row_match.group(2).replace(",", ""))
+ breakdown = row_match.group(3).strip().strip("|").strip()
+ results.append({"name": name, "tests": tests, "breakdown": breakdown})
+
+ return results
+
+
+ def _parse_recent_sprints(memory: str, limit: int = 5) -> list:
+ """Extract recent sprint bullet points from MEMORY.md."""
+ section = _extract_section(memory, "Recent Sprints")
+ if not section:
+ return []
+
+ sprints = []
+ for line in section.split("\n"):
+ line = line.strip()
+ if line.startswith("- ") and ":" in line:
+ sprints.append(line)
+
+ return sprints[-limit:]
+
+
+ def _extract_section(memory: str, heading: str) -> str:
+ """Extract a section from MEMORY.md by heading."""
+ pattern = re.compile(
+ rf"^##\s+{re.escape(heading)}.*?\n(.*?)(?=\n##\s|\Z)",
+ re.DOTALL | re.MULTILINE,
+ )
+ match = pattern.search(memory)
+ return match.group(1).strip() if match else ""
+
+
+ def _parse_debug_tips(section: str) -> list:
+ """Parse debug tips into structured list."""
+ tips = []
+ current_title = ""
+ current_body = []
+
+ for line in section.split("\n"):
+ if line.startswith("### "):
+ if current_title:
+ tips.append({"title": current_title, "body": "\n".join(current_body)})
+ current_title = line[4:].strip()
+ current_body = []
+ elif current_title:
+ current_body.append(line)
+
+ if current_title:
+ tips.append({"title": current_title, "body": "\n".join(current_body)})
+
+ return tips
+
+
+ def _filter_debug_tips(section: str, service_label: str) -> list:
+ """Filter debug tips relevant to a service."""
+ tips = _parse_debug_tips(section)
+ keywords = {
+ "Signage Web": ["blazor", "ef core", "grpc", "controller", "service", "mcp", "web"],
+ "Signage WPF": ["wpf", "canvas", "renderer", "viewmodel", "window", "xaml", "player"],
+ "Common": ["shared", "component", "library", "ui.components"],
+ "MySQL": ["mysql", "ef core", "database", "migration"],
+ "PHP": ["php", "laravel"],
+ }
+ relevant_kw = keywords.get(service_label, [])
+ result = []
+ for tip in tips:
+ full_text = (tip["title"] + " " + tip["body"]).lower()
+ if any(kw in full_text for kw in relevant_kw):
+ result.append(f"**{tip['title']}** — {tip['body'].split(chr(10))[0].strip('- ')}")
+ return result
+
+
+ def _count_cs_files(path: Path) -> int:
+ """Count .cs files in a directory."""
+ if not path.exists():
+ return 0
+ try:
+ result = subprocess.run(
+ ["find", str(path), "-name", "*.cs", "-type", "f"],
+ capture_output=True, text=True, timeout=10,
+ )
+ return len([l for l in result.stdout.strip().split("\n") if l])
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return 0
+
+
+ def _find_public_classes(path: Path) -> list:
+ """Find public class definitions in source files."""
+ if not path.exists():
+ return []
+ try:
+ result = subprocess.run(
+ ["rg", "-n", r"public\s+(class|abstract class|sealed class)\s+(\w+)",
+ "--type", "cs",
+ "--glob", "!**/bin/**", "--glob", "!**/obj/**",
+ "-r", "$2",
+ str(path)],
+ capture_output=True, text=True, timeout=15,
+ )
+ classes = []
+ seen = set()
+ for line in result.stdout.strip().split("\n"):
+ if ":" not in line:
+ continue
+ parts = line.split(":", 2)
+ if len(parts) >= 3:
+ file_path = parts[0].replace(str(path) + "/", "")
+ class_name = parts[2].strip()
+ if class_name and class_name not in seen:
+ seen.add(class_name)
+ classes.append({"name": class_name, "file": file_path})
+ return classes
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+
+
+ def _find_test_classes(path: Path) -> list:
+ """Find test class names."""
+ if not path.exists():
+ return []
+ try:
+ result = subprocess.run(
+ ["rg", "-n", r"public\s+class\s+(\w+Tests?)\b",
+ "--type", "cs",
+ "--glob", "!**/bin/**", "--glob", "!**/obj/**",
+ "-r", "$1",
+ str(path)],
+ capture_output=True, text=True, timeout=15,
+ )
+ classes = []
+ for line in result.stdout.strip().split("\n"):
+ if ":" not in line:
+ continue
+ parts = line.split(":", 2)
+ if len(parts) >= 3:
+ class_name = parts[2].strip()
+ if class_name:
+ classes.append(class_name)
+ return list(set(classes))
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+
+
+ def _git_current_branch(repo_path: Path) -> str:
+ """Get current git branch."""
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=str(repo_path), capture_output=True, text=True, timeout=5,
+ )
+ return result.stdout.strip() or "unknown"
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "unknown"
+
+
+ def _git_last_commit(repo_path: Path) -> str:
+ """Get last commit message."""
+ try:
+ result = subprocess.run(
+ ["git", "log", "-1", "--pretty=format:%h %s (%cr)"],
+ cwd=str(repo_path), capture_output=True, text=True, timeout=5,
+ )
+ return result.stdout.strip() or "no commits"
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "unknown"
+
+
+ def _git_is_dirty(repo_path: Path) -> bool:
+ """Check if working tree has uncommitted changes."""
+ try:
+ result = subprocess.run(
+ ["git", "status", "--porcelain"],
+ cwd=str(repo_path), capture_output=True, text=True, timeout=10,
+ )
+ return bool(result.stdout.strip())
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return False
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## FlowerCore Development Assistant
+
+ Project-aware intelligence for Blue Jay. Provides high-level insights
+ by aggregating project state from MEMORY.md, feature backlog, git repos,
+ and source code.
+
+ ### Available Actions
+
+ | Action | Description | Args |
+ |--------|-------------|------|
+ | `dashboard` | Project-wide status overview | None |
+ | `whats_next` | Recommend next S/M/B tasks | None |
+ | `service` | Deep dive on a specific service | `service` (required) |
+ | `sprint_report` | Generate sprint summary template | `sprint_name`, `before_count`, `after_count` |
+ | `test_gaps` | Find untested classes/services | None |
+ | `debug_tips` | Surface debugging lessons | Optional: `keyword` |
+ | `conventions` | Quick reference (naming, ports, patterns) | None |
+ | `health` | Check repo/tool accessibility | None |
+
+ ### Service Keys
+
+ | Key | Service |
+ |-----|---------|
+ | `signage-web` | Signage Web (Blazor + REST + gRPC + MCP) |
+ | `signage-wpf` | Signage Player WPF (desktop app) |
+ | `common` | Common shared libraries |
+ | `mysql` | MySQL Manager |
+ | `php` | PHP Manager |
+
+ ### Examples
+
+ ```python
+ # Project dashboard
+ {"action": "dashboard"}
+
+ # What should I work on?
+ {"action": "whats_next"}
+
+ # Deep dive on Signage Web
+ {"action": "service", "service": "signage-web"}
+
+ # Sprint report with test delta
+ {"action": "sprint_report", "sprint_name": "Multi-Zone Fix", "before_count": 5305, "after_count": 5326}
+
+ # Find untested code
+ {"action": "test_gaps"}
+
+ # Debug tips about EF Core
+ {"action": "debug_tips", "keyword": "EF Core"}
+
+ # All debug tips
+ {"action": "debug_tips"}
+
+ # Conventions quick reference
+ {"action": "conventions"}
+
+ # Health check
+ {"action": "health"}
+ ```
+
+ ### Related Tools
+
+ For actual operations (not just intelligence):
+ - `flowercore_build` — Build .NET projects
+ - `flowercore_test` — Run xUnit tests
+ - `flowercore_search` — Search codebase
+ - `notes_query` — Read docs, MEMORY.md, sprints
+ - `dotnet_analyzer` — .NET project analysis
+ - `git_repos` — Git repository navigation
+ - `claude_api` — Send prompts to Claude for complex reasoning
+ """
+ flowercore_build.py: |
+ # FlowerCore Build Tool
+ # Builds .NET projects using dotnet.exe (Windows SDK via WSL interop).
+ # Supports specifying project path, configuration, and verbosity.
+ # Parses build output for warnings, errors, and success/failure status.
+
+ import subprocess
+ import os
+ import re
+
+ from python.helpers.tool import Tool, Response
+
+
+ class FlowercoreBuild(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Build a FlowerCore .NET project.
+
+ Args (via self.args):
+ project_path (str): Path to the project directory or .csproj/.slnx file.
+ Relative paths are resolved from /a0/work/repos/FlowerCore/.
+ configuration (str): Build configuration. Default: "Debug".
+ Options: "Debug", "Release".
+ clean (bool): Whether to run a clean before building. Default: False.
+ verbosity (str): MSBuild verbosity level. Default: "minimal".
+ Options: "quiet", "minimal", "normal", "detailed", "diagnostic".
+
+ Returns:
+ Response with structured build results including warnings, errors, and status.
+ """
+ project_path = self.args.get("project_path", "")
+ configuration = self.args.get("configuration", "Debug")
+ clean = self.args.get("clean", False)
+ verbosity = self.args.get("verbosity", "minimal")
+
+ if not project_path:
+ return Response(message="Error: project_path is required. Provide a path to a .csproj, .slnx, or project directory.", break_loop=False)
+
+ # Resolve relative paths against the FlowerCore repos root
+ base_path = "/a0/work/repos/FlowerCore"
+ if not os.path.isabs(project_path):
+ project_path = os.path.join(base_path, project_path)
+
+ # Validate the path exists
+ if not os.path.exists(project_path):
+ return Response(message=f"Error: Path does not exist: {project_path}", break_loop=False)
+
+ # Determine if this is a WPF project (requires dotnet.exe)
+ is_wpf = _detect_wpf(project_path)
+ dotnet_cmd = "dotnet.exe" if is_wpf else "dotnet"
+
+ results = {
+ "project": project_path,
+ "configuration": configuration,
+ "dotnet_command": dotnet_cmd,
+ "is_wpf": is_wpf,
+ "clean": clean,
+ "warnings": [],
+ "errors": [],
+ "status": "unknown",
+ }
+
+ # Run clean if requested
+ if clean:
+ clean_cmd = [dotnet_cmd, "clean", project_path, "-c", configuration, "-v", "quiet"]
+ try:
+ clean_result = subprocess.run(
+ clean_cmd,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ cwd=os.path.dirname(project_path) if os.path.isfile(project_path) else project_path,
+ )
+ if clean_result.returncode != 0:
+ results["clean_output"] = clean_result.stderr.strip()
+ except subprocess.TimeoutExpired:
+ results["clean_output"] = "Clean timed out after 120 seconds"
+ except FileNotFoundError:
+ return Response(message=f"Error: {dotnet_cmd} not found. Ensure the .NET SDK is installed and accessible.", break_loop=False)
+
+ # Run build
+ build_cmd = [
+ dotnet_cmd, "build",
+ project_path,
+ "-c", configuration,
+ "-v", verbosity,
+ "--no-restore" if not clean else "",
+ ]
+ # Remove empty strings from command
+ build_cmd = [c for c in build_cmd if c]
+
+ try:
+ build_result = subprocess.run(
+ build_cmd,
+ capture_output=True,
+ text=True,
+ timeout=300,
+ cwd=os.path.dirname(project_path) if os.path.isfile(project_path) else project_path,
+ )
+ except subprocess.TimeoutExpired:
+ results["status"] = "timeout"
+ return Response(message=_format_results(results, "Build timed out after 300 seconds."), break_loop=False)
+ except FileNotFoundError:
+ return Response(message=f"Error: {dotnet_cmd} not found. Ensure the .NET SDK is installed and accessible.", break_loop=False)
+
+ # Parse output
+ output = build_result.stdout + "\n" + build_result.stderr
+ results["warnings"] = _extract_diagnostics(output, "warning")
+ results["errors"] = _extract_diagnostics(output, "error")
+
+ # Determine status
+ if build_result.returncode == 0:
+ results["status"] = "success"
+ else:
+ results["status"] = "failed"
+
+ # Extract build summary line (e.g., "Build succeeded." or project counts)
+ summary_match = re.search(r"Build (succeeded|FAILED)\.", output)
+ results["summary"] = summary_match.group(0) if summary_match else ""
+
+ # Extract project count from summary
+ project_count_match = re.search(
+ r"(\d+) succeeded, (\d+) failed, (\d+) up-to-date, (\d+) skipped", output
+ )
+ if project_count_match:
+ results["projects_succeeded"] = int(project_count_match.group(1))
+ results["projects_failed"] = int(project_count_match.group(2))
+ results["projects_up_to_date"] = int(project_count_match.group(3))
+ results["projects_skipped"] = int(project_count_match.group(4))
+
+ return Response(message=_format_results(results, output if results["status"] == "failed" else None), break_loop=False)
+
+
+ def _detect_wpf(path: str) -> bool:
+ """Detect if the project is a WPF project by checking for UseWPF in project files."""
+ search_path = path
+ if os.path.isfile(path):
+ search_path = os.path.dirname(path)
+
+ for root, dirs, files in os.walk(search_path):
+ for f in files:
+ if f.endswith(".csproj"):
+ try:
+ with open(os.path.join(root, f), "r", encoding="utf-8") as fh:
+ content = fh.read()
+ if "true" in content or "true" in content:
+ return True
+ except (IOError, UnicodeDecodeError):
+ pass
+ # Only check one level deep for solution-level builds
+ if root != search_path:
+ break
+ return False
+
+
+ def _extract_diagnostics(output: str, level: str) -> list:
+ """Extract warning or error diagnostics from build output."""
+ pattern = re.compile(
+ rf"^(.*?)\((\d+),(\d+)\):\s+{level}\s+([\w\d]+):\s+(.+)$",
+ re.MULTILINE,
+ )
+ diagnostics = []
+ seen = set()
+ for match in pattern.finditer(output):
+ file_path = match.group(1).strip()
+ line = match.group(2)
+ col = match.group(3)
+ code = match.group(4)
+ message = match.group(5).strip()
+ key = f"{file_path}:{line}:{code}"
+ if key not in seen:
+ seen.add(key)
+ diagnostics.append({
+ "file": file_path,
+ "line": int(line),
+ "column": int(col),
+ "code": code,
+ "message": message,
+ })
+ return diagnostics
+
+
+ def _format_results(results: dict, raw_output: str = None) -> str:
+ """Format build results into a readable string."""
+ lines = []
+ lines.append(f"## Build Results")
+ lines.append(f"")
+ lines.append(f"- **Project**: `{results['project']}`")
+ lines.append(f"- **Configuration**: {results['configuration']}")
+ lines.append(f"- **SDK**: `{results['dotnet_command']}` {'(WPF detected)' if results['is_wpf'] else ''}")
+ lines.append(f"- **Status**: {'PASS' if results['status'] == 'success' else 'FAIL'}")
+
+ if results.get("summary"):
+ lines.append(f"- **Summary**: {results['summary']}")
+
+ if results.get("projects_succeeded") is not None:
+ lines.append(
+ f"- **Projects**: {results.get('projects_succeeded', 0)} succeeded, "
+ f"{results.get('projects_failed', 0)} failed, "
+ f"{results.get('projects_up_to_date', 0)} up-to-date"
+ )
+
+ if results["warnings"]:
+ lines.append(f"")
+ lines.append(f"### Warnings ({len(results['warnings'])})")
+ for w in results["warnings"][:20]: # Cap at 20 to avoid flooding
+ lines.append(f"- `{w['code']}` in `{w['file']}:{w['line']}` -- {w['message']}")
+ if len(results["warnings"]) > 20:
+ lines.append(f"- ... and {len(results['warnings']) - 20} more")
+
+ if results["errors"]:
+ lines.append(f"")
+ lines.append(f"### Errors ({len(results['errors'])})")
+ for e in results["errors"][:20]:
+ lines.append(f"- `{e['code']}` in `{e['file']}:{e['line']}` -- {e['message']}")
+ if len(results["errors"]) > 20:
+ lines.append(f"- ... and {len(results['errors']) - 20} more")
+
+ if not results["warnings"] and not results["errors"] and results["status"] == "success":
+ lines.append(f"")
+ lines.append(f"Clean build -- zero warnings, zero errors.")
+
+ if raw_output:
+ lines.append(f"")
+ lines.append(f"### Raw Output (last 50 lines)")
+ lines.append(f"```")
+ raw_lines = raw_output.strip().split("\n")
+ for line in raw_lines[-50:]:
+ lines.append(line)
+ lines.append(f"```")
+
+ return "\n".join(lines)
+ flowercore_search.py: |
+ # FlowerCore Codebase Search Tool
+ # Searches across FlowerCore repositories using ripgrep (rg) or grep fallback.
+ # Returns matching files and line numbers with context.
+
+ import subprocess
+ import os
+ import re
+
+ from python.helpers.tool import Tool, Response
+
+
+ class FlowercoreSearch(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Search the FlowerCore codebase for patterns.
+
+ Args (via self.args):
+ pattern (str): Search pattern (regex supported). Required.
+ glob (str): Optional file glob filter. Examples: "*.cs", "*.razor", "*.csproj",
+ "*.xaml", "*.json", "*.md", "*.yaml".
+ path (str): Subdirectory to search within. Relative to /a0/work/repos/FlowerCore/.
+ Default: searches all of FlowerCore.
+ context (int): Number of context lines before and after each match. Default: 2.
+ case_sensitive (bool): Whether search is case-sensitive. Default: False.
+ max_results (int): Maximum number of matches to return. Default: 50.
+ files_only (bool): Only return matching file paths, not content. Default: False.
+ type (str): File type shortcut. Options: "cs" (C#), "razor" (Blazor), "xaml" (WPF),
+ "csproj" (project files), "json", "yaml", "md", "xml", "sql".
+
+ Returns:
+ Response with matching files, line numbers, and content snippets.
+ """
+ pattern = self.args.get("pattern", "")
+ glob_filter = self.args.get("glob", "")
+ search_path = self.args.get("path", "")
+ context = self.args.get("context", 2)
+ case_sensitive = self.args.get("case_sensitive", False)
+ max_results = self.args.get("max_results", 50)
+ files_only = self.args.get("files_only", False)
+ file_type = self.args.get("type", "")
+
+ if not pattern:
+ return Response(message="Error: pattern is required. Provide a regex pattern to search for.", break_loop=False)
+
+ # Resolve search path
+ base_path = "/a0/work/repos/FlowerCore"
+ if search_path:
+ if not os.path.isabs(search_path):
+ search_path = os.path.join(base_path, search_path)
+ else:
+ search_path = base_path
+
+ if not os.path.exists(search_path):
+ return Response(message=f"Error: Path does not exist: {search_path}", break_loop=False)
+
+ # Map file type shortcuts to glob patterns
+ type_globs = {
+ "cs": "*.cs",
+ "razor": "*.razor",
+ "xaml": "*.xaml",
+ "csproj": "*.csproj",
+ "json": "*.json",
+ "yaml": "*.yaml",
+ "yml": "*.yml",
+ "md": "*.md",
+ "xml": "*.xml",
+ "sql": "*.sql",
+ "css": "*.css",
+ "js": "*.js",
+ "html": "*.html",
+ "slnx": "*.slnx",
+ }
+
+ if file_type and not glob_filter:
+ glob_filter = type_globs.get(file_type, f"*.{file_type}")
+
+ # Try ripgrep first, fall back to grep
+ results = _search_with_rg(pattern, search_path, glob_filter, context, case_sensitive, max_results, files_only)
+ if results is None:
+ results = _search_with_grep(pattern, search_path, glob_filter, context, case_sensitive, max_results, files_only)
+
+ return Response(message=results, break_loop=False)
+
+
+ def _search_with_rg(
+ pattern: str,
+ search_path: str,
+ glob_filter: str,
+ context: int,
+ case_sensitive: bool,
+ max_results: int,
+ files_only: bool,
+ ) -> str | None:
+ """Search using ripgrep (rg)."""
+ cmd = ["rg"]
+
+ if files_only:
+ cmd.append("--files-with-matches")
+ else:
+ cmd.extend(["-n", "--heading"])
+ if context > 0:
+ cmd.extend(["-C", str(context)])
+
+ if not case_sensitive:
+ cmd.append("-i")
+
+ if glob_filter:
+ cmd.extend(["--glob", glob_filter])
+
+ # Exclude common non-code directories
+ cmd.extend([
+ "--glob", "!**/bin/**",
+ "--glob", "!**/obj/**",
+ "--glob", "!**/node_modules/**",
+ "--glob", "!**/.git/**",
+ "--glob", "!**/wwwroot/lib/**",
+ ])
+
+ cmd.extend(["-m", str(max_results)])
+ cmd.append(pattern)
+ cmd.append(search_path)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ except FileNotFoundError:
+ return None # rg not installed, fall back to grep
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 60 seconds. Try a more specific pattern or path."
+
+ if result.returncode == 1:
+ return _format_no_results(pattern, search_path, glob_filter)
+ if result.returncode > 1:
+ return f"Error: ripgrep returned code {result.returncode}: {result.stderr.strip()}"
+
+ return _format_search_results(result.stdout, pattern, search_path, glob_filter, files_only, max_results)
+
+
+ def _search_with_grep(
+ pattern: str,
+ search_path: str,
+ glob_filter: str,
+ context: int,
+ case_sensitive: bool,
+ max_results: int,
+ files_only: bool,
+ ) -> str:
+ """Fallback search using grep."""
+ cmd = ["grep", "-r", "-n"]
+
+ if files_only:
+ cmd.append("-l")
+ elif context > 0:
+ cmd.extend(["-C", str(context)])
+
+ if not case_sensitive:
+ cmd.append("-i")
+
+ if glob_filter:
+ cmd.extend(["--include", glob_filter])
+
+ # Exclude non-code directories
+ cmd.extend([
+ "--exclude-dir=bin",
+ "--exclude-dir=obj",
+ "--exclude-dir=node_modules",
+ "--exclude-dir=.git",
+ ])
+
+ cmd.append(pattern)
+ cmd.append(search_path)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ except FileNotFoundError:
+ return "Error: Neither ripgrep (rg) nor grep found. Cannot search."
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 60 seconds. Try a more specific pattern or path."
+
+ if result.returncode == 1:
+ return _format_no_results(pattern, search_path, glob_filter)
+ if result.returncode > 1:
+ return f"Error: grep returned code {result.returncode}: {result.stderr.strip()}"
+
+ return _format_search_results(result.stdout, pattern, search_path, glob_filter, files_only, max_results)
+
+
+ def _format_no_results(pattern: str, search_path: str, glob_filter: str) -> str:
+ """Format a no-results message."""
+ lines = [
+ f"## Search Results",
+ f"",
+ f"No matches found.",
+ f"- **Pattern**: `{pattern}`",
+ f"- **Path**: `{search_path}`",
+ ]
+ if glob_filter:
+ lines.append(f"- **File filter**: `{glob_filter}`")
+ lines.append(f"")
+ lines.append(f"Try broadening the search: remove the file filter, use a less specific pattern, or search a wider path.")
+ return "\n".join(lines)
+
+
+ def _format_search_results(
+ output: str,
+ pattern: str,
+ search_path: str,
+ glob_filter: str,
+ files_only: bool,
+ max_results: int,
+ ) -> str:
+ """Format search results into a readable string."""
+ lines = output.strip().split("\n")
+ total_lines = len(lines)
+
+ result_lines = [
+ f"## Search Results",
+ f"",
+ f"- **Pattern**: `{pattern}`",
+ f"- **Path**: `{search_path}`",
+ ]
+ if glob_filter:
+ result_lines.append(f"- **File filter**: `{glob_filter}`")
+
+ if files_only:
+ # Deduplicate and count files
+ files = sorted(set(line.strip() for line in lines if line.strip()))
+ result_lines.append(f"- **Matching files**: {len(files)}")
+ result_lines.append(f"")
+ for f in files[:max_results]:
+ # Show path relative to FlowerCore root for readability
+ display_path = f.replace("/a0/work/repos/FlowerCore/", "")
+ result_lines.append(f"- `{display_path}`")
+ if len(files) > max_results:
+ result_lines.append(f"- ... and {len(files) - max_results} more files")
+ else:
+ # Count unique files from output
+ file_count = len(set(
+ re.findall(r"^(/[^\s:]+)", output, re.MULTILINE)
+ ))
+ result_lines.append(f"- **Files with matches**: ~{file_count}")
+ result_lines.append(f"")
+ result_lines.append(f"```")
+ # Cap output to avoid flooding
+ cap = min(total_lines, max_results * 5)
+ for line in lines[:cap]:
+ # Shorten absolute paths for readability
+ display_line = line.replace("/a0/work/repos/FlowerCore/", "")
+ result_lines.append(display_line)
+ if total_lines > cap:
+ result_lines.append(f"... ({total_lines - cap} more lines)")
+ result_lines.append(f"```")
+
+ return "\n".join(result_lines)
+ flowercore_test.py: |
+ # FlowerCore Test Runner Tool
+ # Runs xUnit tests using dotnet.exe test (Windows SDK via WSL interop).
+ # Parses test results for passed/failed/skipped counts.
+ # Supports filtering by test name, class, or namespace.
+
+ import subprocess
+ import os
+ import re
+
+ from python.helpers.tool import Tool, Response
+
+
+ class FlowercoreTest(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Run xUnit tests for a FlowerCore .NET project.
+
+ Args (via self.args):
+ project_path (str): Path to the test project directory or .csproj file.
+ Relative paths are resolved from /a0/work/repos/FlowerCore/.
+ filter (str): Optional test filter expression (dotnet test --filter).
+ Examples: "ClassName=MyTests", "FullyQualifiedName~Thumbnail",
+ "Category=Integration", "DisplayName~should_return".
+ configuration (str): Build configuration. Default: "Debug".
+ no_build (bool): Skip building before running tests. Default: False.
+ verbosity (str): Logger verbosity. Default: "normal".
+ timeout (int): Timeout in seconds. Default: 300.
+
+ Returns:
+ Response with structured test results including pass/fail/skip counts and failure details.
+ """
+ project_path = self.args.get("project_path", "")
+ test_filter = self.args.get("filter", "")
+ configuration = self.args.get("configuration", "Debug")
+ no_build = self.args.get("no_build", False)
+ verbosity = self.args.get("verbosity", "normal")
+ timeout = self.args.get("timeout", 300)
+
+ if not project_path:
+ return Response(message="Error: project_path is required. Provide a path to a test .csproj or test project directory.", break_loop=False)
+
+ # Resolve relative paths against the FlowerCore repos root
+ base_path = "/a0/work/repos/FlowerCore"
+ if not os.path.isabs(project_path):
+ project_path = os.path.join(base_path, project_path)
+
+ # Validate the path exists
+ if not os.path.exists(project_path):
+ return Response(message=f"Error: Path does not exist: {project_path}", break_loop=False)
+
+ # Determine if this is a WPF project (requires dotnet.exe)
+ is_wpf = _detect_wpf(project_path)
+ dotnet_cmd = "dotnet.exe" if is_wpf else "dotnet"
+
+ # Build the test command
+ test_cmd = [
+ dotnet_cmd, "test",
+ project_path,
+ "-c", configuration,
+ "--logger", f"console;verbosity={verbosity}",
+ "--no-restore",
+ ]
+
+ if no_build:
+ test_cmd.append("--no-build")
+
+ if test_filter:
+ test_cmd.extend(["--filter", test_filter])
+
+ results = {
+ "project": project_path,
+ "configuration": configuration,
+ "dotnet_command": dotnet_cmd,
+ "is_wpf": is_wpf,
+ "filter": test_filter or "(none)",
+ "passed": 0,
+ "failed": 0,
+ "skipped": 0,
+ "total": 0,
+ "status": "unknown",
+ "failures": [],
+ "duration": "",
+ }
+
+ # Kill stale test hosts first (can lock DLLs)
+ try:
+ subprocess.run(
+ ["taskkill.exe", "/F", "/IM", "testhost.exe"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ pass # Not critical if this fails
+
+ # Run tests
+ try:
+ test_result = subprocess.run(
+ test_cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ cwd=os.path.dirname(project_path) if os.path.isfile(project_path) else project_path,
+ )
+ except subprocess.TimeoutExpired:
+ results["status"] = "timeout"
+ return Response(message=_format_results(results, f"Tests timed out after {timeout} seconds."), break_loop=False)
+ except FileNotFoundError:
+ return Response(message=f"Error: {dotnet_cmd} not found. Ensure the .NET SDK is installed and accessible.", break_loop=False)
+
+ # Parse output
+ output = test_result.stdout + "\n" + test_result.stderr
+
+ # Extract test counts from summary lines
+ # Patterns: "Passed: 100", "Failed: 2", "Skipped: 0", "Total: 102"
+ # Also: "Passed! - Failed: 0, Passed: 100, Skipped: 0, Total: 100"
+ _parse_test_counts(output, results)
+
+ # Extract individual test failures
+ results["failures"] = _extract_failures(output)
+
+ # Extract duration
+ duration_match = re.search(r"Duration:\s+(.+?)$", output, re.MULTILINE)
+ if duration_match:
+ results["duration"] = duration_match.group(1).strip()
+
+ # Determine overall status
+ if test_result.returncode == 0:
+ results["status"] = "passed"
+ elif results["failed"] > 0:
+ results["status"] = "failed"
+ else:
+ results["status"] = "error"
+
+ return Response(
+ message=_format_results(
+ results,
+ output if results["status"] in ("failed", "error") else None,
+ ),
+ break_loop=False,
+ )
+
+
+ def _detect_wpf(path: str) -> bool:
+ """Detect if the project is a WPF project by checking for UseWPF in project files."""
+ search_path = path
+ if os.path.isfile(path):
+ search_path = os.path.dirname(path)
+
+ for root, dirs, files in os.walk(search_path):
+ for f in files:
+ if f.endswith(".csproj"):
+ try:
+ with open(os.path.join(root, f), "r", encoding="utf-8") as fh:
+ content = fh.read()
+ if "true" in content or "true" in content:
+ return True
+ except (IOError, UnicodeDecodeError):
+ pass
+ if root != search_path:
+ break
+ return False
+
+
+ def _parse_test_counts(output: str, results: dict) -> None:
+ """Parse test count summary from dotnet test output."""
+ # Try the summary line format first:
+ # "Passed! - Failed: 0, Passed: 508, Skipped: 0, Total: 508"
+ # "Failed! - Failed: 2, Passed: 506, Skipped: 0, Total: 508"
+ summary_pattern = re.compile(
+ r"Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+),\s*Total:\s*(\d+)"
+ )
+ matches = summary_pattern.findall(output)
+ if matches:
+ # Sum across all test projects (there may be multiple summary lines)
+ for failed, passed, skipped, total in matches:
+ results["failed"] += int(failed)
+ results["passed"] += int(passed)
+ results["skipped"] += int(skipped)
+ results["total"] += int(total)
+ return
+
+ # Fallback: individual count lines
+ passed_match = re.search(r"Passed:\s+(\d+)", output)
+ failed_match = re.search(r"Failed:\s+(\d+)", output)
+ skipped_match = re.search(r"Skipped:\s+(\d+)", output)
+ total_match = re.search(r"Total:\s+(\d+)", output)
+
+ if passed_match:
+ results["passed"] = int(passed_match.group(1))
+ if failed_match:
+ results["failed"] = int(failed_match.group(1))
+ if skipped_match:
+ results["skipped"] = int(skipped_match.group(1))
+ if total_match:
+ results["total"] = int(total_match.group(1))
+
+ # If we got individual counts but no total, compute it
+ if results["total"] == 0 and (results["passed"] or results["failed"]):
+ results["total"] = results["passed"] + results["failed"] + results["skipped"]
+
+
+ def _extract_failures(output: str) -> list:
+ """Extract individual test failure details from output."""
+ failures = []
+
+ # Pattern for failed test names:
+ # " X FullyQualifiedName.TestMethod [123ms]"
+ # " Failed FullyQualifiedName.TestMethod [123ms]"
+ fail_pattern = re.compile(
+ r"^\s+(?:X|Failed)\s+([\w.]+)\s+\[(\d+\s*m?s)\]",
+ re.MULTILINE,
+ )
+
+ for match in fail_pattern.finditer(output):
+ test_name = match.group(1)
+ duration = match.group(2)
+ failures.append({
+ "test": test_name,
+ "duration": duration,
+ })
+
+ # Also try to capture error messages after "Error Message:"
+ error_blocks = re.split(r"Failed\s+([\w.]+)", output)
+ for i in range(1, len(error_blocks), 2):
+ test_name = error_blocks[i]
+ block = error_blocks[i + 1] if i + 1 < len(error_blocks) else ""
+ error_match = re.search(r"Error Message:\s*\n\s*(.+?)(?:\n\s*Stack Trace:|\n\s*$)", block, re.DOTALL)
+ if error_match:
+ error_msg = error_match.group(1).strip()[:200] # Cap length
+ # Find matching failure entry and add message
+ for f in failures:
+ if f["test"] == test_name and "message" not in f:
+ f["message"] = error_msg
+ break
+
+ return failures
+
+
+ def _format_results(results: dict, raw_output: str = None) -> str:
+ """Format test results into a readable string."""
+ lines = []
+ lines.append(f"## Test Results")
+ lines.append(f"")
+ lines.append(f"- **Project**: `{results['project']}`")
+ lines.append(f"- **Configuration**: {results['configuration']}")
+ lines.append(f"- **SDK**: `{results['dotnet_command']}` {'(WPF detected)' if results['is_wpf'] else ''}")
+ lines.append(f"- **Filter**: {results['filter']}")
+
+ status_label = {
+ "passed": "ALL PASSED",
+ "failed": "FAILURES DETECTED",
+ "error": "ERROR",
+ "timeout": "TIMEOUT",
+ "unknown": "UNKNOWN",
+ }
+ lines.append(f"- **Status**: {status_label.get(results['status'], results['status'])}")
+ lines.append(f"")
+
+ # Test count summary
+ lines.append(f"### Counts")
+ lines.append(f"")
+ lines.append(f"| Metric | Count |")
+ lines.append(f"|--------|-------|")
+ lines.append(f"| Passed | {results['passed']} |")
+ lines.append(f"| Failed | {results['failed']} |")
+ lines.append(f"| Skipped | {results['skipped']} |")
+ lines.append(f"| **Total** | **{results['total']}** |")
+
+ if results["duration"]:
+ lines.append(f"| Duration | {results['duration']} |")
+
+ # Failure details
+ if results["failures"]:
+ lines.append(f"")
+ lines.append(f"### Failed Tests ({len(results['failures'])})")
+ lines.append(f"")
+ for f in results["failures"][:30]: # Cap at 30
+ msg = f" -- {f['message']}" if "message" in f else ""
+ lines.append(f"- `{f['test']}` [{f['duration']}]{msg}")
+ if len(results["failures"]) > 30:
+ lines.append(f"- ... and {len(results['failures']) - 30} more")
+
+ if results["status"] == "passed" and results["skipped"] == 0:
+ lines.append(f"")
+ lines.append(f"All {results['total']} tests passed. Zero skipped. The flock flies in formation.")
+
+ if raw_output:
+ lines.append(f"")
+ lines.append(f"### Raw Output (last 60 lines)")
+ lines.append(f"```")
+ raw_lines = raw_output.strip().split("\n")
+ for line in raw_lines[-60:]:
+ lines.append(line)
+ lines.append(f"```")
+
+ return "\n".join(lines)
+ git_repos.py: |
+ # Git Repository Navigator Tool
+ # Scans and analyzes Git repositories under /a0/work/repos/ (mapped to D:\git).
+ # Provides repository discovery, branch info, recent commits, file counts, and cross-repo search.
+
+ import subprocess
+ import os
+ import re
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class GitRepos(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Navigate and analyze Git repositories.
+
+ Args (via self.args):
+ action (str): The action to perform. Required.
+ Options: "list_repos", "repo_info", "search_repos", "repo_structure",
+ "recent_commits", "list_branches", "file_count"
+ repo_path (str): Relative path to a specific repository (e.g., "FlowerCore/FlowerCore.Notes").
+ Required for: repo_info, repo_structure, recent_commits, list_branches.
+ pattern (str): Search pattern (required for search_repos).
+ glob (str): File filter for search_repos (e.g., "*.cs", "*.md").
+ limit (int): Maximum results to return. Default: 20.
+ include_submodules (bool): Include Git submodules in repo discovery. Default: False.
+
+ Returns:
+ Response with repository information formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ repo_path = self.args.get("repo_path", "")
+ pattern = self.args.get("pattern", "")
+ glob_filter = self.args.get("glob", "")
+ limit = self.args.get("limit", 20)
+ include_submodules = self.args.get("include_submodules", False)
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ base_path = Path("/a0/work/repos")
+ if not base_path.exists():
+ return Response(message=f"Error: Base repository path {base_path} does not exist. Check volume mounts.", break_loop=False)
+
+ # Validate action
+ valid_actions = [
+ "list_repos", "repo_info", "search_repos", "repo_structure",
+ "recent_commits", "list_branches", "file_count",
+ ]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ # Execute action
+ if action == "list_repos":
+ return Response(message=_list_repos(base_path, include_submodules, limit), break_loop=False)
+
+ if action == "repo_info":
+ if not repo_path:
+ return Response(message="Error: repo_path is required for repo_info action", break_loop=False)
+ return Response(message=_repo_info(base_path / repo_path), break_loop=False)
+
+ if action == "search_repos":
+ if not pattern:
+ return Response(message="Error: pattern is required for search_repos action", break_loop=False)
+ return Response(message=_search_repos(base_path, pattern, glob_filter, limit), break_loop=False)
+
+ if action == "repo_structure":
+ if not repo_path:
+ return Response(message="Error: repo_path is required for repo_structure action", break_loop=False)
+ return Response(message=_repo_structure(base_path / repo_path, limit), break_loop=False)
+
+ if action == "recent_commits":
+ if not repo_path:
+ return Response(message="Error: repo_path is required for recent_commits action", break_loop=False)
+ return Response(message=_recent_commits(base_path / repo_path, limit), break_loop=False)
+
+ if action == "list_branches":
+ if not repo_path:
+ return Response(message="Error: repo_path is required for list_branches action", break_loop=False)
+ return Response(message=_list_branches(base_path / repo_path, limit), break_loop=False)
+
+ if action == "file_count":
+ if not repo_path:
+ return Response(message="Error: repo_path is required for file_count action", break_loop=False)
+ return Response(message=_file_count(base_path / repo_path), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented", break_loop=False)
+
+
+ def _list_repos(base_path: Path, include_submodules: bool, limit: int) -> str:
+ """Find all Git repositories under base_path."""
+ repos = []
+
+ # Use find to locate .git directories
+ try:
+ result = subprocess.run(
+ ["find", str(base_path), "-type", "d", "-name", ".git"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ git_dirs = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: find command failed or timed out"
+
+ for git_dir in git_dirs:
+ if not git_dir:
+ continue
+ repo_root = Path(git_dir).parent
+ relative_path = repo_root.relative_to(base_path)
+
+ # Skip submodules unless requested
+ if not include_submodules and (repo_root.parent / ".git" / "modules").exists():
+ continue
+
+ repos.append(str(relative_path))
+
+ repos.sort()
+
+ lines = [
+ f"## Git Repositories ({len(repos)})",
+ "",
+ f"**Base path**: `{base_path}`",
+ "",
+ ]
+
+ for repo in repos[:limit]:
+ lines.append(f"- `{repo}`")
+
+ if len(repos) > limit:
+ lines.append(f"- ... and {len(repos) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _repo_info(repo_path: Path) -> str:
+ """Get repository information: branch, remote, recent activity."""
+ if not repo_path.exists():
+ return f"Error: Repository path does not exist: {repo_path}"
+
+ git_dir = repo_path / ".git"
+ if not git_dir.exists():
+ return f"Error: Not a Git repository: {repo_path}"
+
+ lines = [f"## Repository Info", "", f"**Path**: `{repo_path}`", ""]
+
+ # Current branch
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ branch = result.stdout.strip()
+ lines.append(f"- **Current branch**: `{branch}`")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ lines.append(f"- **Current branch**: unknown")
+
+ # Remote URL
+ try:
+ result = subprocess.run(
+ ["git", "remote", "get-url", "origin"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ remote = result.stdout.strip()
+ lines.append(f"- **Remote origin**: `{remote}`")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ lines.append(f"- **Remote origin**: none")
+
+ # Commit count
+ try:
+ result = subprocess.run(
+ ["git", "rev-list", "--count", "HEAD"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ count = result.stdout.strip()
+ lines.append(f"- **Total commits**: {count}")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ lines.append(f"- **Total commits**: unknown")
+
+ # Last commit
+ try:
+ result = subprocess.run(
+ ["git", "log", "-1", "--pretty=format:%h - %s (%cr by %an)"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ last_commit = result.stdout.strip()
+ lines.append(f"- **Last commit**: {last_commit}")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ lines.append(f"- **Last commit**: unknown")
+
+ # File count
+ try:
+ result = subprocess.run(
+ ["git", "ls-files"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ file_count = len(result.stdout.strip().split("\n"))
+ lines.append(f"- **Tracked files**: {file_count}")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ lines.append(f"- **Tracked files**: unknown")
+
+ return "\n".join(lines)
+
+
+ def _search_repos(base_path: Path, pattern: str, glob_filter: str, limit: int) -> str:
+ """Search across all repositories for a pattern."""
+ cmd = ["rg", "-n", "--heading", "-i", "-m", str(limit)]
+
+ if glob_filter:
+ cmd.extend(["--glob", glob_filter])
+
+ # Exclude common non-code directories
+ cmd.extend([
+ "--glob", "!**/bin/**",
+ "--glob", "!**/obj/**",
+ "--glob", "!**/node_modules/**",
+ "--glob", "!**/.git/**",
+ ])
+
+ cmd.append(pattern)
+ cmd.append(str(base_path))
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ except FileNotFoundError:
+ return "Error: ripgrep (rg) not found. Install ripgrep to use cross-repo search."
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 60 seconds. Try a more specific pattern."
+
+ if result.returncode == 1:
+ return f"## Search Results\n\nNo matches found for pattern: `{pattern}`"
+ if result.returncode > 1:
+ return f"Error: ripgrep returned code {result.returncode}"
+
+ lines = [
+ f"## Cross-Repo Search Results",
+ "",
+ f"- **Pattern**: `{pattern}`",
+ f"- **File filter**: `{glob_filter or 'none'}`",
+ "",
+ f"```",
+ ]
+
+ output_lines = result.stdout.strip().split("\n")
+ for line in output_lines[:limit * 3]: # Cap output
+ # Shorten paths
+ display_line = line.replace(str(base_path) + "/", "")
+ lines.append(display_line)
+
+ if len(output_lines) > limit * 3:
+ lines.append(f"... ({len(output_lines) - limit * 3} more lines)")
+
+ lines.append(f"```")
+ return "\n".join(lines)
+
+
+ def _repo_structure(repo_path: Path, limit: int) -> str:
+ """Show repository directory tree structure."""
+ if not repo_path.exists():
+ return f"Error: Repository path does not exist: {repo_path}"
+
+ try:
+ result = subprocess.run(
+ ["tree", "-L", "3", "-d", "--dirsfirst", str(repo_path)],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0:
+ tree_output = result.stdout.strip()
+ else:
+ # Fallback to ls -R if tree not available
+ raise FileNotFoundError
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ # Fallback: use find
+ try:
+ result = subprocess.run(
+ ["find", str(repo_path), "-maxdepth", "3", "-type", "d"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ dirs = result.stdout.strip().split("\n")[:limit]
+ tree_output = "\n".join(f" {d.replace(str(repo_path), '.')}" for d in dirs)
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: Unable to generate directory structure"
+
+ return f"## Repository Structure\n\n**Path**: `{repo_path}`\n\n```\n{tree_output}\n```"
+
+
+ def _recent_commits(repo_path: Path, limit: int) -> str:
+ """Show recent commits."""
+ if not repo_path.exists():
+ return f"Error: Repository path does not exist: {repo_path}"
+
+ try:
+ result = subprocess.run(
+ ["git", "log", f"-{limit}", "--pretty=format:%h - %s (%cr by %an)"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ commits = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: git command failed or timed out"
+
+ lines = [
+ f"## Recent Commits ({len(commits)})",
+ "",
+ f"**Repository**: `{repo_path}`",
+ "",
+ ]
+
+ for commit in commits:
+ lines.append(f"- {commit}")
+
+ return "\n".join(lines)
+
+
+ def _list_branches(repo_path: Path, limit: int) -> str:
+ """List all branches."""
+ if not repo_path.exists():
+ return f"Error: Repository path does not exist: {repo_path}"
+
+ try:
+ result = subprocess.run(
+ ["git", "branch", "-a"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ branches = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: git command failed or timed out"
+
+ lines = [
+ f"## Branches ({len(branches)})",
+ "",
+ f"**Repository**: `{repo_path}`",
+ "",
+ ]
+
+ for branch in branches[:limit]:
+ branch = branch.strip()
+ if branch.startswith("*"):
+ lines.append(f"- {branch} **(current)**")
+ else:
+ lines.append(f"- {branch}")
+
+ if len(branches) > limit:
+ lines.append(f"- ... and {len(branches) - limit} more")
+
+ return "\n".join(lines)
+
+
+ def _file_count(repo_path: Path) -> str:
+ """Count files by extension."""
+ if not repo_path.exists():
+ return f"Error: Repository path does not exist: {repo_path}"
+
+ try:
+ result = subprocess.run(
+ ["git", "ls-files"],
+ cwd=str(repo_path),
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ files = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: git command failed or timed out"
+
+ # Count by extension
+ ext_counts = {}
+ for file in files:
+ if not file:
+ continue
+ ext = Path(file).suffix or "(no extension)"
+ ext_counts[ext] = ext_counts.get(ext, 0) + 1
+
+ # Sort by count
+ sorted_exts = sorted(ext_counts.items(), key=lambda x: x[1], reverse=True)
+
+ lines = [
+ f"## File Count by Extension",
+ "",
+ f"**Repository**: `{repo_path}`",
+ f"**Total files**: {len(files)}",
+ "",
+ "| Extension | Count |",
+ "|-----------|-------|",
+ ]
+
+ for ext, count in sorted_exts[:20]:
+ lines.append(f"| `{ext}` | {count} |")
+
+ if len(sorted_exts) > 20:
+ lines.append(f"| ... | {len(sorted_exts) - 20} more |")
+
+ return "\n".join(lines)
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## Git Repository Navigator Usage
+
+ Scans and analyzes Git repositories under `/a0/work/repos/` (mapped to `D:\\git`).
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `list_repos` | Find all Git repositories | None |
+ | `repo_info` | Get repository details | `repo_path` |
+ | `search_repos` | Search across all repos | `pattern` |
+ | `repo_structure` | Show directory tree | `repo_path` |
+ | `recent_commits` | Show recent commits | `repo_path` |
+ | `list_branches` | List all branches | `repo_path` |
+ | `file_count` | Count files by extension | `repo_path` |
+
+ ### Examples
+
+ ```python
+ # List all repositories
+ {"action": "list_repos"}
+
+ # Get info about FlowerCore.Notes
+ {"action": "repo_info", "repo_path": "FlowerCore/FlowerCore.Notes"}
+
+ # Search all repos for "IRepository"
+ {"action": "search_repos", "pattern": "IRepository", "glob": "*.cs"}
+
+ # Recent commits
+ {"action": "recent_commits", "repo_path": "FlowerCore/FlowerCore.Notes", "limit": 10}
+
+ # File count breakdown
+ {"action": "file_count", "repo_path": "FlowerCore/FlowerCore.Signage"}
+ ```
+
+ ### Note
+
+ The `repo_path` parameter should be relative to `/a0/work/repos/`.
+ For example: `"FlowerCore/FlowerCore.Notes"` not `"/a0/work/repos/FlowerCore/FlowerCore.Notes"`.
+ """
+ kiwix_search.py: |
+ # Kiwix Offline Wikipedia Search Tool
+ # Searches Simple English Wikipedia via the Kiwix server running in the agent-zero K8s namespace.
+ # Uses curl for HTTP requests — no external Python libraries required.
+
+ import subprocess
+ import os
+ import re
+ import json
+ import urllib.parse
+
+ from python.helpers.tool import Tool, Response
+
+
+ # Book ID matches the ZIM filename without the .zim extension.
+ # Updated monthly — if the ZIM file changes, update this constant.
+ BOOK_ID = "wikipedia_en_simple_all_nopic_2026-02"
+
+ # Output caps
+ ARTICLE_MAX_CHARS = 4000
+ SUMMARY_MAX_CHARS = 1000
+
+
+ class KiwixSearch(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Search and retrieve articles from offline Simple English Wikipedia via Kiwix.
+
+ The Kiwix server hosts a ZIM archive of Simple English Wikipedia (~240,000 articles)
+ with no internet required. Useful for quick definitions, overviews, and factual lookups.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options: "search", "article", "summary", "random", "status"
+ query (str): Search query (required for action "search").
+ title (str): Wikipedia article title (required for actions "article" and "summary").
+ Use underscores for spaces, capitalize first letter.
+ Examples: "Python_(programming_language)", "Kubernetes", "Machine_learning"
+ limit (int): Maximum number of search results. Default: 10. Max: 25.
+
+ Returns:
+ Markdown-formatted search results, article content, or service status.
+ """
+ action = self.args.get("action", "")
+ query = self.args.get("query", "")
+ title = self.args.get("title", "")
+ limit = min(self.args.get("limit", 10), 25)
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = ["search", "article", "summary", "random", "status"]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ base_url = os.environ.get("KIWIX_URL", "http://kiwix:8080")
+
+ if action == "search":
+ if not query:
+ return Response(message="Error: `query` is required for the search action.", break_loop=False)
+ return Response(message=_search(base_url, query, limit), break_loop=False)
+
+ if action == "article":
+ if not title:
+ return Response(message="Error: `title` is required for the article action. Use underscores for spaces (e.g. `Machine_learning`).", break_loop=False)
+ return Response(message=_get_article(base_url, title), break_loop=False)
+
+ if action == "summary":
+ if not title:
+ return Response(message="Error: `title` is required for the summary action. Use underscores for spaces (e.g. `Machine_learning`).", break_loop=False)
+ return Response(message=_get_summary(base_url, title), break_loop=False)
+
+ if action == "random":
+ return Response(message=_get_random(base_url), break_loop=False)
+
+ if action == "status":
+ return Response(message=_get_status(base_url), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented.", break_loop=False)
+
+
+ # ---------------------------------------------------------------------------
+ # HTTP helper
+ # ---------------------------------------------------------------------------
+
+ def _curl(url: str, follow_redirects: bool = True) -> tuple[int, str]:
+ """
+ Fetch a URL using curl. Returns (return_code, response_body).
+
+ return_code:
+ 0 = success (HTTP response received)
+ -1 = curl not found or connection error
+ -2 = timeout
+
+ The response_body may be empty on error.
+ """
+ cmd = ["curl", "-s", "-S", "--max-time", "15"]
+ if follow_redirects:
+ cmd.append("-L")
+ cmd.append(url)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=20, # outer timeout slightly above curl's --max-time
+ )
+ return (result.returncode, result.stdout)
+ except FileNotFoundError:
+ return (-1, "Error: curl is not available in this environment.")
+ except subprocess.TimeoutExpired:
+ return (-2, "Error: Request timed out after 15 seconds.")
+
+
+ def _strip_html(html: str) -> str:
+ """Remove HTML tags and collapse whitespace. No external libraries."""
+ # Remove script and style blocks entirely
+ text = re.sub(r"", "", html, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r"", "", text, flags=re.DOTALL | re.IGNORECASE)
+ # Replace ,
,
,
, heading tags with newlines for readability
+ text = re.sub(r" ", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"(p|div|li|h[1-6]|tr)>", "\n", text, flags=re.IGNORECASE)
+ # Strip remaining tags
+ text = re.sub(r"<[^>]+>", "", text)
+ # Decode common HTML entities
+ text = text.replace("&", "&")
+ text = text.replace("<", "<")
+ text = text.replace(">", ">")
+ text = text.replace(""", '"')
+ text = text.replace("'", "'")
+ text = text.replace(" ", " ")
+ # Collapse runs of whitespace but preserve paragraph breaks
+ text = re.sub(r"[ \t]+", " ", text)
+ text = re.sub(r"\n{3,}", "\n\n", text)
+ return text.strip()
+
+
+ def _article_url(base_url: str, title: str) -> str:
+ """Build the full article URL for a given title."""
+ # Encode the title for URL safety but keep underscores and parens readable
+ safe_title = urllib.parse.quote(title, safe="_()/-")
+ return f"{base_url}/content/{BOOK_ID}/{safe_title}"
+
+
+ # ---------------------------------------------------------------------------
+ # Actions
+ # ---------------------------------------------------------------------------
+
+ def _search(base_url: str, query: str, limit: int) -> str:
+ """Search Wikipedia articles and return a numbered list of results."""
+ encoded_query = urllib.parse.quote(query)
+ url = f"{base_url}/search?pattern={encoded_query}"
+
+ rc, body = _curl(url)
+ if rc != 0:
+ return f"## Wikipedia Search Error\n\n{body}\n\nKiwix may not be running. Try action `status` to check."
+
+ if not body.strip():
+ return f"## Wikipedia Search\n\nEmpty response from Kiwix for query: `{query}`"
+
+ # Extract article links and surrounding text from search results HTML.
+ # Kiwix search results contain links to /content/BOOK_ID/Article_Title
+ # Pattern: Display Text ... snippet ...
+ results = []
+
+ # Strategy 1: Look for article links in the results
+ link_pattern = re.compile(
+ rf'href="(?:/content/{re.escape(BOOK_ID)}/|/)([^"]+)"[^>]*>([^<]+)',
+ re.IGNORECASE,
+ )
+ matches = link_pattern.findall(body)
+
+ # Deduplicate by title, preserving order
+ seen = set()
+ for path, display_text in matches:
+ # Skip navigation/UI links, anchors, and search infrastructure
+ if path.startswith(("#", "search", "catalog", "skin", "viewer")):
+ continue
+ # Clean up the title
+ article_title = urllib.parse.unquote(path).split("/")[-1] if "/" in path else urllib.parse.unquote(path)
+ article_title = article_title.split("#")[0] # strip fragment
+ if not article_title or article_title in seen:
+ continue
+ seen.add(article_title)
+ display = display_text.strip() if display_text.strip() else article_title.replace("_", " ")
+ results.append({"title": article_title, "display": display})
+ if len(results) >= limit:
+ break
+
+ # Strategy 2: If strategy 1 found nothing, try a broader href extraction
+ if not results:
+ href_pattern = re.compile(r'href="[^"]*?/([A-Z][^"#?]*)"', re.IGNORECASE)
+ for m in href_pattern.finditer(body):
+ raw = urllib.parse.unquote(m.group(1))
+ if raw in seen or len(raw) < 2:
+ continue
+ seen.add(raw)
+ results.append({"title": raw, "display": raw.replace("_", " ")})
+ if len(results) >= limit:
+ break
+
+ if not results:
+ return f"## Wikipedia Search\n\nNo results found for: `{query}`\n\nTry different keywords or check spelling."
+
+ # Try to extract snippets from the page for each result
+ snippet_map = _extract_snippets(body)
+
+ lines = [
+ f"## Wikipedia Search: `{query}`",
+ "",
+ f"Found {len(results)} result(s):",
+ "",
+ ]
+
+ for i, r in enumerate(results, 1):
+ snippet = snippet_map.get(r["title"], "")
+ snippet_text = f" -- {snippet}" if snippet else ""
+ lines.append(f"{i}. **{r['display']}**{snippet_text}")
+ lines.append(f" _Use:_ `{{\"action\": \"article\", \"title\": \"{r['title']}\"}}`")
+
+ return "\n".join(lines)
+
+
+ def _extract_snippets(html: str) -> dict:
+ """
+ Try to extract text snippets near article links in search result HTML.
+ Returns a dict mapping article_title -> snippet text.
+ """
+ snippets = {}
+ # Look for patterns where article content/description appears near links
+ # Kiwix often wraps results in or
with class containing "result"
+ result_blocks = re.findall(
+ r'<(?:article|div|li)[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)(?:article|div|li)>',
+ html,
+ re.DOTALL | re.IGNORECASE,
+ )
+
+ for block in result_blocks:
+ # Find the article title link
+ link_match = re.search(r'href="[^"]*?/([^"/#]+)"[^>]*>([^<]+)', block)
+ if not link_match:
+ continue
+ title = urllib.parse.unquote(link_match.group(1))
+ # Get text after the link as snippet
+ text_after = block[link_match.end():]
+ snippet = _strip_html(text_after).strip()
+ # Clean up and truncate snippet
+ snippet = re.sub(r"\s+", " ", snippet).strip()
+ if len(snippet) > 150:
+ snippet = snippet[:147] + "..."
+ if snippet:
+ snippets[title] = snippet
+
+ return snippets
+
+
+ def _get_article(base_url: str, title: str) -> str:
+ """Fetch a full article and return plain text (capped at ARTICLE_MAX_CHARS)."""
+ url = _article_url(base_url, title)
+
+ rc, body = _curl(url)
+ if rc != 0:
+ return f"## Wikipedia Article Error\n\n{body}\n\nKiwix may not be running."
+
+ if not body.strip():
+ return f"## Wikipedia Article: {title.replace('_', ' ')}\n\nEmpty response. The article may not exist in Simple English Wikipedia."
+
+ # Check for 404 / not-found indicators
+ if _is_not_found(body, title):
+ return (
+ f"## Wikipedia Article: {title.replace('_', ' ')}\n\n"
+ f"Article not found. Simple English Wikipedia may not have this article.\n\n"
+ f"**Suggestions:**\n"
+ f"- Search for it: `{{\"action\": \"search\", \"query\": \"{title.replace('_', ' ')}\"}}`\n"
+ f"- Try a different title or spelling\n"
+ f"- Check capitalization (first letter uppercase, rest as-is)"
+ )
+
+ text = _strip_html(body)
+
+ if not text.strip():
+ return f"## Wikipedia Article: {title.replace('_', ' ')}\n\nArticle body was empty after processing."
+
+ # Truncate to cap
+ truncated = False
+ if len(text) > ARTICLE_MAX_CHARS:
+ text = text[:ARTICLE_MAX_CHARS]
+ # Try to break at a sentence boundary
+ last_period = text.rfind(". ")
+ if last_period > ARTICLE_MAX_CHARS * 0.7:
+ text = text[:last_period + 1]
+ truncated = True
+
+ display_title = title.replace("_", " ")
+ lines = [
+ f"## Wikipedia: {display_title}",
+ "",
+ text,
+ ]
+
+ if truncated:
+ lines.append("")
+ lines.append(f"_... (truncated at {ARTICLE_MAX_CHARS} chars. Full article is longer.)_")
+
+ return "\n".join(lines)
+
+
+ def _get_summary(base_url: str, title: str) -> str:
+ """Fetch an article and return only the first 1-2 paragraphs."""
+ url = _article_url(base_url, title)
+
+ rc, body = _curl(url)
+ if rc != 0:
+ return f"## Wikipedia Summary Error\n\n{body}\n\nKiwix may not be running."
+
+ if not body.strip():
+ return f"## Wikipedia Summary: {title.replace('_', ' ')}\n\nEmpty response. The article may not exist."
+
+ if _is_not_found(body, title):
+ return (
+ f"## Wikipedia Summary: {title.replace('_', ' ')}\n\n"
+ f"Article not found in Simple English Wikipedia.\n\n"
+ f"Try searching: `{{\"action\": \"search\", \"query\": \"{title.replace('_', ' ')}\"}}`"
+ )
+
+ # Try to extract the first paragraph(s) from the HTML before stripping.
+ # Wikipedia articles typically have
tags for content paragraphs.
+ paragraphs = re.findall(r"
]*>(.*?)
", body, re.DOTALL | re.IGNORECASE)
+
+ summary_parts = []
+ for p in paragraphs:
+ cleaned = _strip_html(p).strip()
+ # Skip very short paragraphs (navigation, empty, coordinates, etc.)
+ if len(cleaned) < 20:
+ continue
+ summary_parts.append(cleaned)
+ # Stop after 2 substantial paragraphs
+ if len(summary_parts) >= 2:
+ break
+
+ if summary_parts:
+ summary = "\n\n".join(summary_parts)
+ else:
+ # Fallback: strip all HTML and take the first chunk
+ text = _strip_html(body)
+ summary = text[:SUMMARY_MAX_CHARS] if text else "No content could be extracted."
+
+ # Cap summary length
+ if len(summary) > SUMMARY_MAX_CHARS:
+ summary = summary[:SUMMARY_MAX_CHARS]
+ last_period = summary.rfind(". ")
+ if last_period > SUMMARY_MAX_CHARS * 0.6:
+ summary = summary[:last_period + 1]
+
+ display_title = title.replace("_", " ")
+ lines = [
+ f"## Wikipedia Summary: {display_title}",
+ "",
+ summary,
+ "",
+ f"_For the full article:_ `{{\"action\": \"article\", \"title\": \"{title}\"}}`",
+ ]
+
+ return "\n".join(lines)
+
+
+ def _get_random(base_url: str) -> str:
+ """Fetch a random article from Kiwix."""
+ # Kiwix supports a /random endpoint that redirects to a random article.
+ # We use curl with -L to follow the redirect.
+ url = f"{base_url}/random"
+
+ # First, try without following redirects to capture the redirect location
+ cmd = [
+ "curl", "-s", "-S", "--max-time", "15",
+ "-o", "/dev/null", # discard body
+ "-w", "%{url_effective}\\n%{http_code}",
+ "-L", # follow redirects
+ url,
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=20,
+ )
+ except FileNotFoundError:
+ return "## Random Article Error\n\nError: curl is not available."
+ except subprocess.TimeoutExpired:
+ return "## Random Article Error\n\nRequest timed out."
+
+ if result.returncode != 0:
+ return f"## Random Article Error\n\nCould not reach Kiwix at `{base_url}`. Is the service running?"
+
+ # Parse the effective URL to extract the article title
+ output_lines = result.stdout.strip().split("\n")
+ effective_url = output_lines[0] if output_lines else ""
+
+ # Extract title from URL like /content/BOOK_ID/Article_Title
+ title_match = re.search(rf"/content/{re.escape(BOOK_ID)}/(.+?)(?:\?|#|$)", effective_url)
+
+ if not title_match:
+ # Fallback: try any /content/ path or just use /A/ path
+ title_match = re.search(r"/content/[^/]+/(.+?)(?:\?|#|$)", effective_url)
+
+ if not title_match:
+ # Random endpoint may not be available — try fetching a known article as fallback
+ return (
+ "## Random Article\n\n"
+ "The Kiwix random endpoint did not return a valid article.\n\n"
+ "Try searching instead: `{\"action\": \"search\", \"query\": \"science\"}`"
+ )
+
+ title = urllib.parse.unquote(title_match.group(1))
+
+ # Now fetch the summary for this random article
+ return _get_summary(base_url, title)
+
+
+ def _get_status(base_url: str) -> str:
+ """Check Kiwix service status and available content."""
+ lines = [
+ "## Kiwix Service Status",
+ "",
+ ]
+
+ # Check main endpoint
+ rc, body = _curl(f"{base_url}/", follow_redirects=False)
+ if rc != 0:
+ lines.append(f"**Status:** OFFLINE")
+ lines.append(f"- **URL:** `{base_url}`")
+ lines.append(f"- **Error:** {body}")
+ lines.append("")
+ lines.append("The Kiwix pod may not be running. Check with `kubectl get pods -n agent-zero`.")
+ return "\n".join(lines)
+
+ lines.append(f"**Status:** ONLINE")
+ lines.append(f"- **URL:** `{base_url}`")
+ lines.append(f"- **Book ID:** `{BOOK_ID}`")
+ lines.append("")
+
+ # Check catalog for available ZIM files
+ rc_cat, catalog_body = _curl(f"{base_url}/catalog/root.xml")
+ if rc_cat == 0 and catalog_body.strip():
+ # Extract entry titles from OPDS catalog
+ entries = re.findall(r"]*>([^<]+)", catalog_body, re.IGNORECASE)
+ # Filter out generic OPDS titles
+ zim_entries = [e for e in entries if e and "catalog" not in e.lower() and "opds" not in e.lower()]
+
+ if zim_entries:
+ lines.append("### Available Content")
+ lines.append("")
+ for entry in zim_entries:
+ lines.append(f"- {entry}")
+ else:
+ lines.append("### Available Content")
+ lines.append("")
+ lines.append(f"- Simple English Wikipedia (`{BOOK_ID}`)")
+ else:
+ lines.append("### Available Content")
+ lines.append("")
+ lines.append(f"- Simple English Wikipedia (`{BOOK_ID}`) _(catalog endpoint unavailable)_")
+
+ # Quick connectivity test: try fetching a known article
+ lines.append("")
+ lines.append("### Connectivity Test")
+ lines.append("")
+
+ rc_test, test_body = _curl(_article_url(base_url, "Earth"))
+ if rc_test == 0 and test_body.strip() and not _is_not_found(test_body, "Earth"):
+ lines.append("- Article fetch: **OK** (retrieved 'Earth')")
+ elif rc_test == 0:
+ lines.append("- Article fetch: **DEGRADED** (server responded but article not found)")
+ else:
+ lines.append("- Article fetch: **FAILED** (could not retrieve test article)")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ def _is_not_found(body: str, title: str) -> bool:
+ """Heuristic check for 404 / article-not-found responses."""
+ lower = body.lower()
+ # Kiwix returns various indicators for missing articles
+ if "404" in lower and "not found" in lower:
+ return True
+ if "no results" in lower and title.lower().replace("_", " ") not in lower:
+ return True
+ if "404" in lower:
+ return True
+ # Very short response with no real content is suspicious
+ text = _strip_html(body)
+ if len(text.strip()) < 30:
+ return True
+ return False
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## Kiwix Wikipedia Search Tool
+
+ Search and read offline Simple English Wikipedia via the Kiwix server.
+ No internet required -- content is served from a local ZIM archive.
+
+ ### Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `search` | Search for Wikipedia articles | `query` |
+ | `article` | Get full article content (plain text) | `title` |
+ | `summary` | Get first 1-2 paragraphs of an article | `title` |
+ | `random` | Get a random article summary | _(none)_ |
+ | `status` | Check Kiwix service health | _(none)_ |
+
+ ### Examples
+
+ ```json
+ {"action": "search", "query": "machine learning", "limit": 5}
+
+ {"action": "article", "title": "Kubernetes"}
+
+ {"action": "summary", "title": "Python_(programming_language)"}
+
+ {"action": "random"}
+
+ {"action": "status"}
+ ```
+
+ ### Tips
+
+ - Article titles use **underscores** for spaces and are **case-sensitive** (first letter capitalized).
+ - Use parentheses for disambiguation: `Python_(programming_language)`, `Mercury_(planet)`.
+ - Simple English Wikipedia has ~240,000 articles with simplified vocabulary.
+ - If an article is not found, try `search` first to find the correct title.
+ - Article output is capped at 4000 characters. Summaries are capped at 1000 characters.
+ """
+ kubectl_manager.py: |
+ # Kubernetes Cluster Management Tool
+ # Manages Kubernetes resources via kubectl on a Rancher Desktop (k3s) cluster.
+ # The pod runs with a cluster-admin ServiceAccount so all operations are permitted.
+ # kubectl is located at /usr/local/bin/kubectl.
+
+ import subprocess
+ import os
+ import shutil
+ import tempfile
+
+ from python.helpers.tool import Tool, Response
+
+
+ KUBECTL = "/usr/local/bin/kubectl"
+ MAX_OUTPUT = 4000
+
+
+ class KubectlManager(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Kubernetes cluster management tool via kubectl.
+
+ Args (via self.args):
+ action (str): The action to perform. Required.
+ Options: "get_pods", "get_resources", "describe", "logs",
+ "apply", "delete", "scale", "exec_command",
+ "port_forward", "rollout", "top", "get_events",
+ "cluster_info"
+ resource_type (str): Kubernetes resource type (pods, deployments, services, etc.).
+ Required for: get_resources, describe, delete, top.
+ name (str): Resource name.
+ Required for: describe, logs, delete, scale, exec_command,
+ port_forward, rollout.
+ namespace (str): Kubernetes namespace. Default varies by action.
+ labels (str): Label selector for filtering (e.g., "app=nginx,tier=frontend").
+ Optional for: get_pods.
+ container (str): Container name within a pod.
+ Optional for: logs, exec_command.
+ tail (int): Number of log lines to return. Default: 100.
+ Optional for: logs.
+ previous (bool): Show logs from previous container instance. Default: False.
+ Optional for: logs.
+ yaml_content (str): Inline YAML manifest content.
+ Required for: apply.
+ replicas (int): Desired replica count.
+ Required for: scale.
+ command (str): Command to execute inside the pod.
+ Required for: exec_command.
+ local_port (int): Local port for port forwarding.
+ Required for: port_forward.
+ remote_port (int): Remote port for port forwarding.
+ Required for: port_forward.
+ subcommand (str): Rollout subcommand (status, restart, undo).
+ Required for: rollout.
+ sort_by (str): Sort field for events. Default: "lastTimestamp".
+ Optional for: get_events.
+
+ Returns:
+ Response with kubectl output formatted as markdown.
+ """
+ # Validate kubectl exists
+ if not _kubectl_exists():
+ return Response(
+ message="Error: kubectl not found at `/usr/local/bin/kubectl`. "
+ "Ensure kubectl is installed in the container.",
+ break_loop=False,
+ )
+
+ action = self.args.get("action", "")
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = [
+ "get_pods", "get_resources", "describe", "logs",
+ "apply", "delete", "scale", "exec_command",
+ "port_forward", "rollout", "top", "get_events",
+ "cluster_info",
+ ]
+ if action not in valid_actions:
+ return Response(
+ message=f"Error: Invalid action '{action}'. "
+ f"Valid actions: {', '.join(valid_actions)}",
+ break_loop=False,
+ )
+
+ if action == "get_pods":
+ return Response(message=_get_pods(self.args), break_loop=False)
+
+ if action == "get_resources":
+ return Response(message=_get_resources(self.args), break_loop=False)
+
+ if action == "describe":
+ return Response(message=_describe(self.args), break_loop=False)
+
+ if action == "logs":
+ return Response(message=_logs(self.args), break_loop=False)
+
+ if action == "apply":
+ return Response(message=_apply(self.args), break_loop=False)
+
+ if action == "delete":
+ return Response(message=_delete(self.args), break_loop=False)
+
+ if action == "scale":
+ return Response(message=_scale(self.args), break_loop=False)
+
+ if action == "exec_command":
+ return Response(message=_exec_command(self.args), break_loop=False)
+
+ if action == "port_forward":
+ return Response(message=_port_forward(self.args), break_loop=False)
+
+ if action == "rollout":
+ return Response(message=_rollout(self.args), break_loop=False)
+
+ if action == "top":
+ return Response(message=_top(self.args), break_loop=False)
+
+ if action == "get_events":
+ return Response(message=_get_events(self.args), break_loop=False)
+
+ if action == "cluster_info":
+ return Response(message=_cluster_info(), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented", break_loop=False)
+
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ def _kubectl_exists() -> bool:
+ """Check if kubectl binary exists and is executable."""
+ return shutil.which(KUBECTL) is not None or os.path.isfile(KUBECTL)
+
+
+ def _run_kubectl(args: list, timeout: int = 30) -> tuple:
+ """
+ Run a kubectl command and return (returncode, stdout, stderr).
+ Handles timeouts and missing binary gracefully.
+ """
+ cmd = [KUBECTL] + args
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ return result.returncode, result.stdout, result.stderr
+ except subprocess.TimeoutExpired:
+ return -1, "", f"Command timed out after {timeout} seconds"
+ except FileNotFoundError:
+ return -1, "", "kubectl binary not found"
+
+
+ def _truncate(text: str, limit: int = MAX_OUTPUT) -> str:
+ """Truncate text to limit characters, appending a notice if truncated."""
+ if len(text) <= limit:
+ return text
+ return text[:limit] + f"\n\n... (truncated, {len(text) - limit} chars omitted)"
+
+
+ def _ns_args(namespace: str, default_all: bool = False) -> list:
+ """Build namespace arguments for kubectl commands."""
+ if namespace:
+ return ["-n", namespace]
+ if default_all:
+ return ["--all-namespaces"]
+ return ["-n", "default"]
+
+
+ # ---------------------------------------------------------------------------
+ # Actions
+ # ---------------------------------------------------------------------------
+
+ def _get_pods(args: dict) -> str:
+ """List pods with optional namespace and label filtering."""
+ namespace = args.get("namespace", "")
+ labels = args.get("labels", "")
+
+ cmd = ["get", "pods"]
+ cmd.extend(_ns_args(namespace, default_all=True))
+ if labels:
+ cmd.extend(["-l", labels])
+ cmd.extend(["-o", "wide"])
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ "## Pods",
+ "",
+ f"- **Namespace**: `{namespace or 'all'}`",
+ ]
+ if labels:
+ lines.append(f"- **Labels**: `{labels}`")
+ lines.append("")
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ return "\n".join(lines)
+
+ output = stdout.strip()
+ if not output:
+ lines.append("No pods found.")
+ else:
+ lines.append(f"```\n{_truncate(output)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _get_resources(args: dict) -> str:
+ """List any Kubernetes resource type."""
+ resource_type = args.get("resource_type", "")
+ namespace = args.get("namespace", "")
+
+ if not resource_type:
+ return "Error: `resource_type` is required for get_resources (e.g., pods, deployments, services, configmaps, secrets, pvc, ingress, jobs)."
+
+ cmd = ["get", resource_type]
+ cmd.extend(_ns_args(namespace, default_all=True))
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Resources: {resource_type}",
+ "",
+ f"- **Type**: `{resource_type}`",
+ f"- **Namespace**: `{namespace or 'all'}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ return "\n".join(lines)
+
+ output = stdout.strip()
+ if not output:
+ lines.append(f"No {resource_type} found.")
+ else:
+ lines.append(f"```\n{_truncate(output)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _describe(args: dict) -> str:
+ """Describe a specific Kubernetes resource."""
+ resource_type = args.get("resource_type", "")
+ name = args.get("name", "")
+ namespace = args.get("namespace", "default")
+
+ if not resource_type:
+ return "Error: `resource_type` is required for describe."
+ if not name:
+ return "Error: `name` is required for describe."
+
+ cmd = ["describe", resource_type, name, "-n", namespace]
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Describe: {resource_type}/{name}",
+ "",
+ f"- **Type**: `{resource_type}`",
+ f"- **Name**: `{name}`",
+ f"- **Namespace**: `{namespace}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ return "\n".join(lines)
+
+ output = _truncate(stdout.strip())
+ lines.append(f"```\n{output}\n```")
+
+ return "\n".join(lines)
+
+
+ def _logs(args: dict) -> str:
+ """Get logs from a pod."""
+ name = args.get("name", "")
+ namespace = args.get("namespace", "default")
+ container = args.get("container", "")
+ tail = int(args.get("tail", 100))
+ previous = args.get("previous", False)
+
+ if not name:
+ return "Error: `name` is required for logs."
+
+ cmd = ["logs", name, "-n", namespace, "--tail", str(tail)]
+ if container:
+ cmd.extend(["-c", container])
+ if previous:
+ cmd.append("--previous")
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Logs: {name}",
+ "",
+ f"- **Pod**: `{name}`",
+ f"- **Namespace**: `{namespace}`",
+ f"- **Tail**: {tail} lines",
+ ]
+ if container:
+ lines.append(f"- **Container**: `{container}`")
+ if previous:
+ lines.append("- **Previous**: yes")
+ lines.append("")
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ return "\n".join(lines)
+
+ output = stdout.strip()
+ if not output:
+ lines.append("No log output.")
+ else:
+ lines.append(f"```\n{_truncate(output)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _apply(args: dict) -> str:
+ """Apply a YAML manifest."""
+ yaml_content = args.get("yaml_content", "")
+ namespace = args.get("namespace", "")
+
+ if not yaml_content:
+ return "Error: `yaml_content` is required for apply."
+
+ # Write YAML to a temp file
+ tmp_path = None
+ try:
+ fd, tmp_path = tempfile.mkstemp(suffix=".yaml", prefix="kubectl-apply-", dir="/tmp")
+ with os.fdopen(fd, "w") as f:
+ f.write(yaml_content)
+
+ cmd = ["apply", "-f", tmp_path]
+ if namespace:
+ cmd.extend(["-n", namespace])
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+ finally:
+ if tmp_path and os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ lines = [
+ "## Apply Manifest",
+ "",
+ ]
+ if namespace:
+ lines.append(f"- **Namespace**: `{namespace}`")
+ lines.append("")
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ else:
+ output = stdout.strip()
+ if output:
+ lines.append("### Result")
+ lines.append(f"```\n{output}\n```")
+ else:
+ lines.append("Applied successfully (no output).")
+
+ # Include any warnings from stderr
+ warnings = stderr.strip()
+ if warnings:
+ lines.append("")
+ lines.append("### Warnings")
+ lines.append(f"```\n{_truncate(warnings)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _delete(args: dict) -> str:
+ """Delete a Kubernetes resource."""
+ resource_type = args.get("resource_type", "")
+ name = args.get("name", "")
+ namespace = args.get("namespace", "default")
+
+ if not resource_type:
+ return "Error: `resource_type` is required for delete."
+ if not name:
+ return "Error: `name` is required for delete."
+
+ cmd = ["delete", resource_type, name, "-n", namespace]
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Delete: {resource_type}/{name}",
+ "",
+ f"- **Type**: `{resource_type}`",
+ f"- **Name**: `{name}`",
+ f"- **Namespace**: `{namespace}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ else:
+ output = stdout.strip() or stderr.strip()
+ lines.append(f"### Result")
+ lines.append(f"```\n{output}\n```")
+
+ return "\n".join(lines)
+
+
+ def _scale(args: dict) -> str:
+ """Scale a deployment to a given replica count."""
+ name = args.get("name", "")
+ replicas = args.get("replicas", None)
+ namespace = args.get("namespace", "default")
+
+ if not name:
+ return "Error: `name` is required for scale."
+ if replicas is None:
+ return "Error: `replicas` is required for scale."
+
+ replicas = int(replicas)
+ cmd = ["scale", f"deployment/{name}", f"--replicas={replicas}", "-n", namespace]
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Scale: deployment/{name}",
+ "",
+ f"- **Deployment**: `{name}`",
+ f"- **Replicas**: {replicas}",
+ f"- **Namespace**: `{namespace}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ else:
+ output = stdout.strip() or stderr.strip()
+ lines.append(f"### Result")
+ lines.append(f"```\n{output}\n```")
+
+ return "\n".join(lines)
+
+
+ def _exec_command(args: dict) -> str:
+ """Execute a command inside a pod."""
+ name = args.get("name", "")
+ command = args.get("command", "")
+ namespace = args.get("namespace", "default")
+ container = args.get("container", "")
+
+ if not name:
+ return "Error: `name` is required for exec_command."
+ if not command:
+ return "Error: `command` is required for exec_command."
+
+ cmd = ["exec", name, "-n", namespace]
+ if container:
+ cmd.extend(["-c", container])
+ cmd.append("--")
+ # Split the command string into parts for proper execution
+ cmd.extend(command.split())
+
+ rc, stdout, stderr = _run_kubectl(cmd, timeout=30)
+
+ lines = [
+ f"## Exec: {name}",
+ "",
+ f"- **Pod**: `{name}`",
+ f"- **Namespace**: `{namespace}`",
+ f"- **Command**: `{command}`",
+ ]
+ if container:
+ lines.append(f"- **Container**: `{container}`")
+ lines.append("")
+
+ if rc == -1 and "timed out" in stderr:
+ lines.append("### Result: TIMEOUT")
+ lines.append(f"Command did not complete within 30 seconds.")
+ return "\n".join(lines)
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ combined = (stdout.strip() + "\n" + stderr.strip()).strip()
+ lines.append(f"```\n{_truncate(combined)}\n```")
+ else:
+ output = stdout.strip()
+ if not output:
+ lines.append("Command executed successfully (no output).")
+ else:
+ lines.append(f"```\n{_truncate(output)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _port_forward(args: dict) -> str:
+ """Start port forwarding to a pod in the background."""
+ name = args.get("name", "")
+ local_port = args.get("local_port", None)
+ remote_port = args.get("remote_port", None)
+ namespace = args.get("namespace", "default")
+
+ if not name:
+ return "Error: `name` is required for port_forward."
+ if local_port is None:
+ return "Error: `local_port` is required for port_forward."
+ if remote_port is None:
+ return "Error: `remote_port` is required for port_forward."
+
+ local_port = int(local_port)
+ remote_port = int(remote_port)
+
+ cmd = [
+ KUBECTL, "port-forward", name,
+ f"{local_port}:{remote_port}",
+ "-n", namespace,
+ ]
+
+ lines = [
+ f"## Port Forward: {name}",
+ "",
+ f"- **Pod**: `{name}`",
+ f"- **Namespace**: `{namespace}`",
+ f"- **Mapping**: `localhost:{local_port}` -> `{name}:{remote_port}`",
+ "",
+ ]
+
+ try:
+ # Start as background process
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ pid = process.pid
+
+ lines.append("### Result: STARTED")
+ lines.append(f"- **PID**: {pid}")
+ lines.append(f"- **Access via**: `localhost:{local_port}`")
+ lines.append(f"- **Stop with**: `kill {pid}`")
+ except FileNotFoundError:
+ lines.append("### Error")
+ lines.append("kubectl binary not found.")
+ except Exception as e:
+ lines.append("### Error")
+ lines.append(f"Failed to start port forwarding: {e}")
+
+ return "\n".join(lines)
+
+
+ def _rollout(args: dict) -> str:
+ """Manage deployment rollouts (status, restart, undo)."""
+ subcommand = args.get("subcommand", "")
+ name = args.get("name", "")
+ namespace = args.get("namespace", "default")
+
+ if not subcommand:
+ return "Error: `subcommand` is required for rollout (status, restart, undo)."
+ if not name:
+ return "Error: `name` is required for rollout."
+
+ valid_subcmds = ["status", "restart", "undo"]
+ if subcommand not in valid_subcmds:
+ return f"Error: Invalid rollout subcommand '{subcommand}'. Valid: {', '.join(valid_subcmds)}"
+
+ cmd = ["rollout", subcommand, f"deployment/{name}", "-n", namespace]
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Rollout {subcommand.title()}: deployment/{name}",
+ "",
+ f"- **Deployment**: `{name}`",
+ f"- **Subcommand**: `{subcommand}`",
+ f"- **Namespace**: `{namespace}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ else:
+ output = stdout.strip() or stderr.strip()
+ lines.append(f"### Result")
+ lines.append(f"```\n{output}\n```")
+
+ return "\n".join(lines)
+
+
+ def _top(args: dict) -> str:
+ """Show resource usage for pods or nodes."""
+ resource_type = args.get("resource_type", "")
+ namespace = args.get("namespace", "")
+ name = args.get("name", "")
+
+ if not resource_type:
+ return "Error: `resource_type` is required for top (pods or nodes)."
+
+ valid_types = ["pods", "nodes"]
+ if resource_type not in valid_types:
+ return f"Error: Invalid resource_type '{resource_type}' for top. Valid: {', '.join(valid_types)}"
+
+ cmd = ["top", resource_type]
+ if name:
+ cmd.append(name)
+ if resource_type == "pods":
+ cmd.extend(_ns_args(namespace, default_all=True))
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ f"## Resource Usage: {resource_type}",
+ "",
+ f"- **Type**: `{resource_type}`",
+ ]
+ if name:
+ lines.append(f"- **Name**: `{name}`")
+ if resource_type == "pods":
+ lines.append(f"- **Namespace**: `{namespace or 'all'}`")
+ lines.append("")
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ err_text = stderr.strip()
+ if "Metrics API not available" in err_text or "metrics" in err_text.lower():
+ lines.append("Metrics Server is not installed or not ready.")
+ lines.append("Install with: `kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml`")
+ else:
+ lines.append(f"```\n{_truncate(err_text)}\n```")
+ return "\n".join(lines)
+
+ output = stdout.strip()
+ if not output:
+ lines.append(f"No usage data available for {resource_type}.")
+ else:
+ lines.append(f"```\n{_truncate(output)}\n```")
+
+ return "\n".join(lines)
+
+
+ def _get_events(args: dict) -> str:
+ """Get cluster events, sorted and tailed."""
+ namespace = args.get("namespace", "")
+ sort_by = args.get("sort_by", "lastTimestamp")
+
+ cmd = ["get", "events"]
+ cmd.extend(_ns_args(namespace, default_all=True))
+ cmd.extend(["--sort-by=.metadata.creationTimestamp"])
+
+ rc, stdout, stderr = _run_kubectl(cmd)
+
+ lines = [
+ "## Cluster Events",
+ "",
+ f"- **Namespace**: `{namespace or 'all'}`",
+ f"- **Sort by**: `{sort_by}`",
+ "",
+ ]
+
+ if rc != 0:
+ lines.append(f"### Error (exit code {rc})")
+ lines.append(f"```\n{_truncate(stderr.strip())}\n```")
+ return "\n".join(lines)
+
+ output = stdout.strip()
+ if not output:
+ lines.append("No events found.")
+ else:
+ # Tail to last 30 events (plus header line)
+ event_lines = output.split("\n")
+ header = event_lines[0] if event_lines else ""
+ data_lines = event_lines[1:] if len(event_lines) > 1 else []
+
+ if len(data_lines) > 30:
+ tailed = [header] + data_lines[-30:]
+ lines.append(f"Showing last 30 of {len(data_lines)} events:")
+ lines.append("")
+ lines.append(f"```\n{chr(10).join(tailed)}\n```")
+ else:
+ lines.append(f"```\n{output}\n```")
+
+ return "\n".join(lines)
+
+
+ def _cluster_info() -> str:
+ """Get a combined cluster overview: cluster-info, nodes, and namespaces."""
+ lines = [
+ "## Cluster Overview",
+ "",
+ ]
+
+ # 1. cluster-info
+ rc, stdout, stderr = _run_kubectl(["cluster-info"])
+ lines.append("### Cluster Info")
+ lines.append("")
+ if rc == 0:
+ # Strip ANSI color codes from cluster-info output
+ import re
+ clean_output = re.sub(r"\x1b\[[0-9;]*m", "", stdout.strip())
+ lines.append(f"```\n{clean_output}\n```")
+ else:
+ lines.append(f"```\n{stderr.strip()}\n```")
+ lines.append("")
+
+ # 2. Nodes
+ rc, stdout, stderr = _run_kubectl(["get", "nodes", "-o", "wide"])
+ lines.append("### Nodes")
+ lines.append("")
+ if rc == 0:
+ lines.append(f"```\n{stdout.strip()}\n```")
+ else:
+ lines.append(f"```\n{stderr.strip()}\n```")
+ lines.append("")
+
+ # 3. Namespaces
+ rc, stdout, stderr = _run_kubectl(["get", "ns"])
+ lines.append("### Namespaces")
+ lines.append("")
+ if rc == 0:
+ lines.append(f"```\n{stdout.strip()}\n```")
+ else:
+ lines.append(f"```\n{stderr.strip()}\n```")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Usage
+ # ---------------------------------------------------------------------------
+
+ def _show_usage() -> str:
+ """Return help text listing all available actions."""
+ return """## Kubernetes Cluster Management Tool
+
+ Manage Kubernetes resources on a Rancher Desktop (k3s) cluster via kubectl.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `get_pods` | List pods (all namespaces by default) | None |
+ | `get_resources` | List any resource type | `resource_type` |
+ | `describe` | Describe a resource | `resource_type`, `name` |
+ | `logs` | Get pod logs | `name` |
+ | `apply` | Apply a YAML manifest | `yaml_content` |
+ | `delete` | Delete a resource | `resource_type`, `name` |
+ | `scale` | Scale a deployment | `name`, `replicas` |
+ | `exec_command` | Execute a command in a pod | `name`, `command` |
+ | `port_forward` | Start port forwarding (background) | `name`, `local_port`, `remote_port` |
+ | `rollout` | Manage rollouts (status/restart/undo) | `subcommand`, `name` |
+ | `top` | Show resource usage | `resource_type` (pods or nodes) |
+ | `get_events` | Get cluster events | None |
+ | `cluster_info` | Get cluster overview | None |
+
+ ### Common Optional Args
+
+ | Arg | Default | Description |
+ |-----|---------|-------------|
+ | `namespace` | varies | Kubernetes namespace (default: all or "default") |
+ | `labels` | (none) | Label selector for filtering pods |
+ | `container` | (none) | Container name (for logs, exec_command) |
+ | `tail` | `100` | Number of log lines to return |
+ | `previous` | `false` | Show logs from previous container instance |
+ | `sort_by` | `lastTimestamp` | Sort field for events |
+
+ ### Examples
+
+ ```python
+ # List all pods across all namespaces
+ {"action": "get_pods"}
+
+ # List pods in a specific namespace with labels
+ {"action": "get_pods", "namespace": "fc-system", "labels": "app=signage-web"}
+
+ # List deployments in a namespace
+ {"action": "get_resources", "resource_type": "deployments", "namespace": "default"}
+
+ # Describe a pod
+ {"action": "describe", "resource_type": "pod", "name": "nginx-abc123", "namespace": "default"}
+
+ # Get pod logs (last 50 lines)
+ {"action": "logs", "name": "nginx-abc123", "namespace": "default", "tail": 50}
+
+ # Apply a YAML manifest
+ {"action": "apply", "yaml_content": "apiVersion: v1\\nkind: ConfigMap\\nmetadata:\\n name: test-config\\ndata:\\n key: value"}
+
+ # Delete a service
+ {"action": "delete", "resource_type": "service", "name": "my-service", "namespace": "default"}
+
+ # Scale a deployment to 3 replicas
+ {"action": "scale", "name": "my-app", "replicas": 3, "namespace": "default"}
+
+ # Execute a command in a pod
+ {"action": "exec_command", "name": "nginx-abc123", "command": "cat /etc/nginx/nginx.conf"}
+
+ # Start port forwarding
+ {"action": "port_forward", "name": "pod/my-app-abc123", "local_port": 8080, "remote_port": 80}
+
+ # Check rollout status
+ {"action": "rollout", "subcommand": "status", "name": "my-app"}
+
+ # Restart a deployment
+ {"action": "rollout", "subcommand": "restart", "name": "my-app"}
+
+ # Show pod resource usage
+ {"action": "top", "resource_type": "pods", "namespace": "default"}
+
+ # Show node resource usage
+ {"action": "top", "resource_type": "nodes"}
+
+ # Get recent cluster events
+ {"action": "get_events", "namespace": "default"}
+
+ # Get full cluster overview
+ {"action": "cluster_info"}
+ ```
+
+ ### Notes
+
+ - The Agent Zero pod has `cluster-admin` privileges; all kubectl operations are permitted.
+ - kubectl is located at `/usr/local/bin/kubectl`.
+ - Long outputs are truncated to 4000 characters to avoid flooding.
+ - The `exec_command` action has a 30-second timeout.
+ - The `port_forward` action runs in the background and returns the PID.
+ - Events are tailed to the last 30 entries by default.
+ """
+ namecheap_api.py: |
+ # Namecheap API Tool
+ # Manage domains, DNS records, SSL certificates, and DDNS via the Namecheap API.
+ # All DNS modifications use get-then-set to preserve existing records.
+ # Security: API key is stored locally (air-gap, never transmitted except to Namecheap).
+
+ import xml.etree.ElementTree as ET
+ import urllib.request
+ import urllib.parse
+ import json
+
+ from python.helpers.tool import Tool, Response
+
+
+ # Namecheap API credentials and endpoints
+ _NAMECHEAP_API_KEY = "a3fe4c817c234338b800321a2e0e3de8"
+ _NAMECHEAP_USER = "astoltz"
+ _NAMECHEAP_BASE_URL = "https://api.namecheap.com/xml.response"
+ _DDNS_URL = "https://dynamicdns.park-your-domain.com/update"
+ _IPIFY_URL = "https://api.ipify.org"
+
+ # Namecheap XML namespace
+ _NC_NS = "http://api.namecheap.com/xml.response"
+
+
+ class NamecheapApi(Tool):
+ """Manage Namecheap domains, DNS records, and SSL certificates."""
+
+ async def execute(self, **kwargs) -> Response:
+ """
+ Namecheap API tool for domain, DNS, and SSL management.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options: "list_domains", "get_dns", "set_dns", "remove_dns",
+ "update_ddns", "get_info", "list_ssl", "set_nameservers"
+ domain (str): Domain name (e.g., "flowercore.io", "iamwork.in").
+ Required for: get_dns, set_dns, remove_dns, update_ddns,
+ get_info, set_nameservers.
+ record_type (str): DNS record type (A, AAAA, CNAME, MX, TXT, NS, URL, URL301, FRAME).
+ Required for: set_dns, remove_dns.
+ host_name (str): DNS host/subdomain (e.g., "@", "www", "mail").
+ Required for: set_dns, remove_dns.
+ address (str): DNS record value/address.
+ Required for: set_dns.
+ ttl (int): DNS record TTL in seconds. Default: 1800.
+ mx_pref (int): MX priority. Default: 10. Only used for MX records.
+ password (str): DDNS password from Namecheap.
+ Required for: update_ddns.
+ ip (str): IP address for DDNS update. Optional (auto-detects if omitted).
+ nameservers (str): Comma-separated nameservers.
+ Required for: set_nameservers.
+ page_size (int): Results per page for list_domains. Default: 100.
+
+ Returns:
+ Structured results formatted as markdown.
+
+ Security:
+ - API key is stored locally; never exposed in output.
+ - Client IP is auto-detected via ipify.org for API authentication.
+ - All DNS changes are logged with before/after state.
+ - IP whitelist errors are flagged with instructions.
+ """
+ action = self.args.get("action", "")
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = [
+ "list_domains", "get_dns", "set_dns", "remove_dns",
+ "update_ddns", "get_info", "list_ssl", "set_nameservers",
+ ]
+ if action not in valid_actions:
+ return Response(
+ message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}",
+ break_loop=False,
+ )
+
+ domain = self.args.get("domain", "")
+
+ if action == "list_domains":
+ page_size = int(self.args.get("page_size", 100))
+ return Response(message=_list_domains(page_size), break_loop=False)
+
+ if action == "get_dns":
+ if not domain:
+ return Response(message="Error: `domain` is required for get_dns.", break_loop=False)
+ return Response(message=_get_dns(domain), break_loop=False)
+
+ if action == "set_dns":
+ host_name = self.args.get("host_name", "")
+ record_type = self.args.get("record_type", "")
+ address = self.args.get("address", "")
+ ttl = int(self.args.get("ttl", 1800))
+ mx_pref = int(self.args.get("mx_pref", 10))
+ if not domain:
+ return Response(message="Error: `domain` is required for set_dns.", break_loop=False)
+ if not host_name:
+ return Response(message="Error: `host_name` is required for set_dns.", break_loop=False)
+ if not record_type:
+ return Response(message="Error: `record_type` is required for set_dns.", break_loop=False)
+ if not address:
+ return Response(message="Error: `address` is required for set_dns.", break_loop=False)
+ return Response(
+ message=_set_dns(domain, host_name, record_type, address, ttl, mx_pref),
+ break_loop=False,
+ )
+
+ if action == "remove_dns":
+ host_name = self.args.get("host_name", "")
+ record_type = self.args.get("record_type", "")
+ if not domain:
+ return Response(message="Error: `domain` is required for remove_dns.", break_loop=False)
+ if not host_name:
+ return Response(message="Error: `host_name` is required for remove_dns.", break_loop=False)
+ if not record_type:
+ return Response(message="Error: `record_type` is required for remove_dns.", break_loop=False)
+ return Response(message=_remove_dns(domain, host_name, record_type), break_loop=False)
+
+ if action == "update_ddns":
+ host_name = self.args.get("host_name", "@")
+ password = self.args.get("password", "")
+ ip = self.args.get("ip", "")
+ if not domain:
+ return Response(message="Error: `domain` is required for update_ddns.", break_loop=False)
+ if not password:
+ return Response(message="Error: `password` is required for update_ddns.", break_loop=False)
+ return Response(message=_update_ddns(domain, host_name, password, ip), break_loop=False)
+
+ if action == "get_info":
+ if not domain:
+ return Response(message="Error: `domain` is required for get_info.", break_loop=False)
+ return Response(message=_get_info(domain), break_loop=False)
+
+ if action == "list_ssl":
+ return Response(message=_list_ssl(), break_loop=False)
+
+ if action == "set_nameservers":
+ nameservers = self.args.get("nameservers", "")
+ if not domain:
+ return Response(message="Error: `domain` is required for set_nameservers.", break_loop=False)
+ if not nameservers:
+ return Response(
+ message="Error: `nameservers` is required for set_nameservers (comma-separated).",
+ break_loop=False,
+ )
+ return Response(message=_set_nameservers(domain, nameservers), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented.", break_loop=False)
+
+
+ # ---------------------------------------------------------------------------
+ # Internal helpers
+ # ---------------------------------------------------------------------------
+
+
+ def _split_domain(domain: str) -> tuple:
+ """Split a domain into SLD and TLD.
+
+ Examples:
+ flowercore.io -> ("flowercore", "io")
+ iamwork.in -> ("iamwork", "in")
+ sub.example.co.uk -> ("sub.example", "co.uk")
+ """
+ # Known two-part TLDs
+ two_part_tlds = {
+ "co.uk", "org.uk", "me.uk", "net.uk",
+ "co.nz", "net.nz", "org.nz",
+ "com.au", "net.au", "org.au",
+ "co.za", "co.in", "co.jp",
+ }
+ parts = domain.lower().strip().split(".")
+ if len(parts) >= 3:
+ candidate = ".".join(parts[-2:])
+ if candidate in two_part_tlds:
+ return ".".join(parts[:-2]), candidate
+ if len(parts) >= 2:
+ return ".".join(parts[:-1]), parts[-1]
+ return domain, ""
+
+
+ def _get_client_ip() -> str:
+ """Detect the public IP of this machine via ipify."""
+ try:
+ req = urllib.request.Request(_IPIFY_URL, method="GET")
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return resp.read().decode("utf-8").strip()
+ except Exception as e:
+ raise RuntimeError(f"Failed to detect client IP via ipify: {e}")
+
+
+ def _api_call(command: str, extra_params: dict = None) -> ET.Element:
+ """Make a Namecheap API call and return the parsed XML root.
+
+ Raises RuntimeError on HTTP errors or Namecheap API errors.
+ """
+ client_ip = _get_client_ip()
+
+ params = {
+ "ApiUser": _NAMECHEAP_USER,
+ "ApiKey": _NAMECHEAP_API_KEY,
+ "UserName": _NAMECHEAP_USER,
+ "ClientIp": client_ip,
+ "Command": command,
+ }
+ if extra_params:
+ params.update(extra_params)
+
+ url = f"{_NAMECHEAP_BASE_URL}?{urllib.parse.urlencode(params)}"
+
+ try:
+ req = urllib.request.Request(url, method="GET")
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ body = resp.read().decode("utf-8")
+ except Exception as e:
+ raise RuntimeError(f"HTTP request failed for {command}: {e}")
+
+ root = ET.fromstring(body)
+
+ # Check for API-level errors
+ status = root.attrib.get("Status", "")
+ if status == "ERROR":
+ errors = root.findall(f".//{{{_NC_NS}}}Error")
+ if not errors:
+ # Try without namespace
+ errors = root.findall(".//Error")
+ err_msgs = []
+ for err in errors:
+ num = err.attrib.get("Number", "?")
+ err_msgs.append(f"[{num}] {err.text}")
+ err_text = "; ".join(err_msgs) if err_msgs else "Unknown API error"
+
+ # Check for IP whitelist issue
+ if any("IP" in m for m in err_msgs):
+ err_text += (
+ f"\n\nHint: Your current IP ({client_ip}) may not be whitelisted. "
+ "Go to Namecheap > Profile > Tools > API Access to add it."
+ )
+ raise RuntimeError(f"Namecheap API error ({command}): {err_text}")
+
+ return root
+
+
+ def _find_elements(root: ET.Element, tag: str) -> list:
+ """Find elements by tag, trying with and without namespace."""
+ elements = root.findall(f".//{{{_NC_NS}}}{tag}")
+ if not elements:
+ elements = root.findall(f".//{tag}")
+ return elements
+
+
+ def _get_hosts(domain: str) -> list:
+ """Fetch current DNS host records for a domain. Returns list of dicts."""
+ sld, tld = _split_domain(domain)
+ root = _api_call("namecheap.domains.dns.getHosts", {
+ "SLD": sld,
+ "TLD": tld,
+ })
+
+ records = []
+ for host_el in _find_elements(root, "host"):
+ records.append({
+ "HostId": host_el.attrib.get("HostId", ""),
+ "Name": host_el.attrib.get("Name", ""),
+ "Type": host_el.attrib.get("Type", ""),
+ "Address": host_el.attrib.get("Address", ""),
+ "MXPref": host_el.attrib.get("MXPref", "10"),
+ "TTL": host_el.attrib.get("TTL", "1800"),
+ })
+ return records
+
+
+ def _set_hosts(domain: str, records: list) -> ET.Element:
+ """Set the full DNS host list for a domain. Returns API response root."""
+ sld, tld = _split_domain(domain)
+ params = {
+ "SLD": sld,
+ "TLD": tld,
+ }
+ for i, rec in enumerate(records, start=1):
+ params[f"HostName{i}"] = rec["Name"]
+ params[f"RecordType{i}"] = rec["Type"]
+ params[f"Address{i}"] = rec["Address"]
+ params[f"MXPref{i}"] = rec.get("MXPref", "10")
+ params[f"TTL{i}"] = rec.get("TTL", "1800")
+
+ return _api_call("namecheap.domains.dns.setHosts", params)
+
+
+ def _format_records_table(records: list) -> str:
+ """Format a list of DNS records as a markdown table."""
+ if not records:
+ return "No DNS records found."
+
+ lines = [
+ "| # | Host | Type | Address | MX | TTL |",
+ "|---|------|------|---------|----|-----|",
+ ]
+ for i, rec in enumerate(records, start=1):
+ name = rec.get("Name", "")
+ rtype = rec.get("Type", "")
+ addr = rec.get("Address", "")
+ mx = rec.get("MXPref", "")
+ ttl = rec.get("TTL", "")
+ # Truncate long addresses for display
+ addr_display = addr if len(addr) <= 60 else addr[:57] + "..."
+ lines.append(f"| {i} | `{name}` | {rtype} | `{addr_display}` | {mx} | {ttl} |")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action implementations
+ # ---------------------------------------------------------------------------
+
+
+ def _list_domains(page_size: int) -> str:
+ """List all domains in the Namecheap account."""
+ lines = ["## Namecheap Domains", ""]
+
+ try:
+ root = _api_call("namecheap.domains.getList", {
+ "PageSize": str(page_size),
+ })
+ except RuntimeError as e:
+ return f"## Namecheap Domains\n\nError: {e}"
+
+ domains = _find_elements(root, "Domain")
+ if not domains:
+ lines.append("No domains found.")
+ return "\n".join(lines)
+
+ lines.append(f"**Total domains**: {len(domains)}")
+ lines.append("")
+ lines.append("| Domain | Expires | AutoRenew | Locked | DNS |")
+ lines.append("|--------|---------|-----------|--------|-----|")
+
+ for d in domains:
+ name = d.attrib.get("Name", "")
+ expires = d.attrib.get("Expires", "")
+ auto_renew = d.attrib.get("AutoRenew", "")
+ is_locked = d.attrib.get("IsLocked", "")
+ # Shorten date to just the date portion
+ if " " in expires:
+ expires = expires.split(" ")[0]
+ lines.append(f"| `{name}` | {expires} | {auto_renew} | {is_locked} | -- |")
+
+ return "\n".join(lines)
+
+
+ def _get_dns(domain: str) -> str:
+ """Get DNS records for a domain."""
+ lines = [f"## DNS Records: {domain}", ""]
+
+ try:
+ records = _get_hosts(domain)
+ except RuntimeError as e:
+ return f"## DNS Records: {domain}\n\nError: {e}"
+
+ lines.append(f"**Records**: {len(records)}")
+ lines.append("")
+ lines.append(_format_records_table(records))
+
+ return "\n".join(lines)
+
+
+ def _set_dns(domain: str, host_name: str, record_type: str,
+ address: str, ttl: int, mx_pref: int) -> str:
+ """Add or update a DNS record, preserving all existing records.
+
+ CRITICAL: Namecheap setHosts replaces ALL records. We must fetch
+ existing records first, merge the change, then set the full list.
+ """
+ lines = [f"## Set DNS Record: {domain}", ""]
+ record_type = record_type.upper()
+
+ # Step 1: Fetch existing records
+ try:
+ existing = _get_hosts(domain)
+ except RuntimeError as e:
+ return f"## Set DNS Record: {domain}\n\nError fetching existing records: {e}"
+
+ lines.append(f"**Existing records**: {len(existing)}")
+ lines.append("")
+
+ # Step 2: Merge — update if matching host+type exists, otherwise add
+ new_record = {
+ "Name": host_name,
+ "Type": record_type,
+ "Address": address,
+ "MXPref": str(mx_pref),
+ "TTL": str(ttl),
+ }
+
+ updated = False
+ merged = []
+ for rec in existing:
+ if rec["Name"].lower() == host_name.lower() and rec["Type"].upper() == record_type:
+ # Replace existing record with new values
+ merged.append(new_record)
+ updated = True
+ lines.append(f"**Action**: Updated existing `{record_type}` record for `{host_name}`")
+ lines.append(f"- **Old value**: `{rec['Address']}`")
+ lines.append(f"- **New value**: `{address}`")
+ else:
+ merged.append(rec)
+
+ if not updated:
+ merged.append(new_record)
+ lines.append(f"**Action**: Added new `{record_type}` record for `{host_name}` -> `{address}`")
+
+ lines.append(f"**Total records after merge**: {len(merged)}")
+ lines.append("")
+
+ # Step 3: Set the full record list
+ try:
+ _set_hosts(domain, merged)
+ except RuntimeError as e:
+ lines.append(f"**FAILED to set records**: {e}")
+ lines.append("")
+ lines.append("**WARNING**: No records were changed. The original records are intact.")
+ return "\n".join(lines)
+
+ lines.append("### Result: SUCCESS")
+ lines.append("")
+ lines.append("### Current Records")
+ lines.append("")
+
+ # Step 4: Verify by re-fetching
+ try:
+ final = _get_hosts(domain)
+ lines.append(_format_records_table(final))
+ except RuntimeError:
+ lines.append(_format_records_table(merged))
+ lines.append("")
+ lines.append("*(Verification fetch failed; showing expected state)*")
+
+ return "\n".join(lines)
+
+
+ def _remove_dns(domain: str, host_name: str, record_type: str) -> str:
+ """Remove a DNS record by host name and type, preserving all others.
+
+ CRITICAL: Namecheap setHosts replaces ALL records. We fetch existing,
+ filter out the target, then set the remaining list.
+ """
+ lines = [f"## Remove DNS Record: {domain}", ""]
+ record_type = record_type.upper()
+
+ # Step 1: Fetch existing records
+ try:
+ existing = _get_hosts(domain)
+ except RuntimeError as e:
+ return f"## Remove DNS Record: {domain}\n\nError fetching existing records: {e}"
+
+ lines.append(f"**Existing records**: {len(existing)}")
+ lines.append("")
+
+ # Step 2: Filter out matching records
+ remaining = []
+ removed = []
+ for rec in existing:
+ if rec["Name"].lower() == host_name.lower() and rec["Type"].upper() == record_type:
+ removed.append(rec)
+ else:
+ remaining.append(rec)
+
+ if not removed:
+ lines.append(f"**No matching record found**: `{record_type}` for `{host_name}`")
+ lines.append("")
+ lines.append("### Current Records")
+ lines.append("")
+ lines.append(_format_records_table(existing))
+ return "\n".join(lines)
+
+ for rec in removed:
+ lines.append(f"**Removing**: `{rec['Type']}` `{rec['Name']}` -> `{rec['Address']}`")
+ lines.append(f"**Records after removal**: {len(remaining)}")
+ lines.append("")
+
+ # Step 3: Safety check — don't set empty if domain had records
+ if not remaining and existing:
+ lines.append("**WARNING**: This would remove ALL DNS records for the domain.")
+ lines.append("Refusing to set an empty record list. Remove records individually")
+ lines.append("or use `set_nameservers` to change DNS providers.")
+ return "\n".join(lines)
+
+ # Step 4: Set the remaining records
+ try:
+ _set_hosts(domain, remaining)
+ except RuntimeError as e:
+ lines.append(f"**FAILED to set records**: {e}")
+ lines.append("")
+ lines.append("**WARNING**: No records were changed. The original records are intact.")
+ return "\n".join(lines)
+
+ lines.append("### Result: SUCCESS")
+ lines.append("")
+ lines.append("### Remaining Records")
+ lines.append("")
+
+ # Step 5: Verify
+ try:
+ final = _get_hosts(domain)
+ lines.append(_format_records_table(final))
+ except RuntimeError:
+ lines.append(_format_records_table(remaining))
+ lines.append("")
+ lines.append("*(Verification fetch failed; showing expected state)*")
+
+ return "\n".join(lines)
+
+
+ def _update_ddns(domain: str, host_name: str, password: str, ip: str) -> str:
+ """Update a Dynamic DNS record via Namecheap's DDNS interface."""
+ sld, tld = _split_domain(domain)
+ lines = [f"## DDNS Update: {host_name}.{domain}", ""]
+
+ # Auto-detect IP if not provided
+ if not ip:
+ try:
+ ip = _get_client_ip()
+ lines.append(f"**Auto-detected IP**: `{ip}`")
+ except RuntimeError as e:
+ return f"## DDNS Update: {host_name}.{domain}\n\nError detecting IP: {e}"
+ else:
+ lines.append(f"**Specified IP**: `{ip}`")
+
+ lines.append("")
+
+ params = {
+ "host": host_name,
+ "domain": domain,
+ "password": password,
+ "ip": ip,
+ }
+
+ url = f"{_DDNS_URL}?{urllib.parse.urlencode(params)}"
+
+ try:
+ req = urllib.request.Request(url, method="GET")
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ body = resp.read().decode("utf-8")
+ except Exception as e:
+ lines.append(f"**Error**: Failed to update DDNS: {e}")
+ return "\n".join(lines)
+
+ # Parse the XML response
+ try:
+ root = ET.fromstring(body)
+ # DDNS response has with , , ,
+ err_count_el = root.find(".//ErrCount")
+ err_count = int(err_count_el.text) if err_count_el is not None else -1
+
+ if err_count == 0:
+ ip_el = root.find(".//IP")
+ updated_ip = ip_el.text if ip_el is not None else ip
+ lines.append("### Result: SUCCESS")
+ lines.append(f"- **IP set to**: `{updated_ip}`")
+ lines.append(f"- **Host**: `{host_name}.{domain}`")
+ else:
+ err_el = root.find(".//Err1")
+ err_text = err_el.text if err_el is not None else "Unknown error"
+ lines.append("### Result: FAILED")
+ lines.append(f"- **Error**: {err_text}")
+ if "password" in err_text.lower():
+ lines.append("- **Hint**: Check the DDNS password in Namecheap > Advanced DNS > Dynamic DNS")
+ except ET.ParseError:
+ lines.append("### Response (raw)")
+ lines.append("```")
+ lines.append(body[:500])
+ lines.append("```")
+
+ return "\n".join(lines)
+
+
+ def _get_info(domain: str) -> str:
+ """Get detailed domain information."""
+ lines = [f"## Domain Info: {domain}", ""]
+
+ try:
+ root = _api_call("namecheap.domains.getInfo", {
+ "DomainName": domain,
+ })
+ except RuntimeError as e:
+ return f"## Domain Info: {domain}\n\nError: {e}"
+
+ # Extract domain info
+ domain_el = _find_elements(root, "DomainGetInfoResult")
+ if not domain_el:
+ lines.append("No domain info returned.")
+ return "\n".join(lines)
+
+ d = domain_el[0]
+ lines.append(f"- **Domain**: `{d.attrib.get('DomainName', domain)}`")
+ lines.append(f"- **Owner**: {d.attrib.get('OwnerName', 'N/A')}")
+ lines.append(f"- **Status**: {d.attrib.get('Status', 'N/A')}")
+ lines.append(f"- **Created**: {d.attrib.get('CreatedDate', 'N/A')}")
+ lines.append(f"- **Expires**: {d.attrib.get('ExpiredDate', 'N/A')}")
+ lines.append(f"- **Auto-Renew**: {d.attrib.get('IsAutoRenew', 'N/A')}")
+ lines.append(f"- **Locked**: {d.attrib.get('IsLocked', 'N/A')}")
+ lines.append(f"- **Premium**: {d.attrib.get('IsPremium', 'N/A')}")
+ lines.append("")
+
+ # Nameservers
+ ns_elements = _find_elements(root, "Nameserver")
+ if ns_elements:
+ lines.append("### Nameservers")
+ lines.append("")
+ for ns in ns_elements:
+ lines.append(f"- `{ns.text}`")
+ lines.append("")
+
+ # DNS provider info
+ dns_details = _find_elements(root, "DnsDetails")
+ if dns_details:
+ dd = dns_details[0]
+ provider = dd.attrib.get("ProviderType", "")
+ using_nc = dd.attrib.get("IsUsingOurDNS", "")
+ lines.append("### DNS Provider")
+ lines.append("")
+ lines.append(f"- **Provider**: {provider}")
+ lines.append(f"- **Using Namecheap DNS**: {using_nc}")
+ lines.append("")
+
+ # Whois guard
+ wg_elements = _find_elements(root, "Whoisguard")
+ if wg_elements:
+ wg = wg_elements[0]
+ lines.append("### WhoisGuard")
+ lines.append("")
+ lines.append(f"- **Enabled**: {wg.attrib.get('Enabled', 'N/A')}")
+ lines.append(f"- **Expires**: {wg.attrib.get('ExpiredDate', 'N/A')}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+ def _list_ssl() -> str:
+ """List SSL certificates in the Namecheap account."""
+ lines = ["## Namecheap SSL Certificates", ""]
+
+ try:
+ root = _api_call("namecheap.ssl.getList", {
+ "PageSize": "100",
+ })
+ except RuntimeError as e:
+ return f"## Namecheap SSL Certificates\n\nError: {e}"
+
+ certs = _find_elements(root, "CertificateInfo")
+ if not certs:
+ # Try alternate element name
+ certs = _find_elements(root, "SSLListResult")
+ if certs:
+ # SSLListResult contains child CertificateInfo elements
+ certs = _find_elements(root, "CertificateInfo")
+
+ if not certs:
+ lines.append("No SSL certificates found.")
+ return "\n".join(lines)
+
+ lines.append(f"**Total certificates**: {len(certs)}")
+ lines.append("")
+ lines.append("| ID | Host | Type | Status | Expires |")
+ lines.append("|----|------|------|--------|---------|")
+
+ for c in certs:
+ cert_id = c.attrib.get("CertificateID", "")
+ host = c.attrib.get("HostName", "N/A")
+ ssl_type = c.attrib.get("SSLType", c.attrib.get("Type", "N/A"))
+ status = c.attrib.get("Status", "N/A")
+ expires = c.attrib.get("ExpireDate", c.attrib.get("Expires", "N/A"))
+ lines.append(f"| {cert_id} | `{host}` | {ssl_type} | {status} | {expires} |")
+
+ return "\n".join(lines)
+
+
+ def _set_nameservers(domain: str, nameservers: str) -> str:
+ """Set custom nameservers for a domain."""
+ sld, tld = _split_domain(domain)
+ lines = [f"## Set Nameservers: {domain}", ""]
+
+ ns_list = [ns.strip() for ns in nameservers.split(",") if ns.strip()]
+ if not ns_list:
+ return f"## Set Nameservers: {domain}\n\nError: No valid nameservers provided."
+
+ lines.append("**Nameservers to set**:")
+ for ns in ns_list:
+ lines.append(f"- `{ns}`")
+ lines.append("")
+
+ try:
+ _api_call("namecheap.domains.dns.setCustom", {
+ "SLD": sld,
+ "TLD": tld,
+ "Nameservers": ",".join(ns_list),
+ })
+ except RuntimeError as e:
+ lines.append(f"**Error**: {e}")
+ return "\n".join(lines)
+
+ lines.append("### Result: SUCCESS")
+ lines.append("")
+ lines.append(f"Custom nameservers set for `{domain}`:")
+ for ns in ns_list:
+ lines.append(f"- `{ns}`")
+ lines.append("")
+ lines.append("**Note**: DNS propagation may take up to 48 hours.")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Usage help
+ # ---------------------------------------------------------------------------
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## Namecheap API Tool
+
+ Manage Namecheap domains, DNS records, SSL certificates, and Dynamic DNS.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `list_domains` | List all domains in account | None |
+ | `get_dns` | Get DNS records for a domain | `domain` |
+ | `set_dns` | Add or update a DNS record (preserves existing) | `domain`, `host_name`, `record_type`, `address` |
+ | `remove_dns` | Remove a DNS record (preserves others) | `domain`, `host_name`, `record_type` |
+ | `update_ddns` | Update Dynamic DNS IP | `domain`, `password` |
+ | `get_info` | Get detailed domain information | `domain` |
+ | `list_ssl` | List SSL certificates | None |
+ | `set_nameservers` | Set custom nameservers | `domain`, `nameservers` |
+
+ ### Common Optional Args
+
+ | Arg | Default | Description |
+ |-----|---------|-------------|
+ | `ttl` | `1800` | DNS record TTL in seconds |
+ | `mx_pref` | `10` | MX record priority |
+ | `host_name` | `@` | Subdomain or `@` for root (DDNS only) |
+ | `ip` | (auto) | IP for DDNS (auto-detects via ipify if omitted) |
+ | `page_size` | `100` | Results per page for list_domains |
+
+ ### Examples
+
+ ```python
+ # List all domains
+ {"action": "list_domains"}
+
+ # Get DNS records
+ {"action": "get_dns", "domain": "flowercore.io"}
+
+ # Add an A record (preserves existing records)
+ {"action": "set_dns", "domain": "flowercore.io", "host_name": "app", "record_type": "A", "address": "74.40.140.17"}
+
+ # Add a CNAME record
+ {"action": "set_dns", "domain": "iamwork.in", "host_name": "www", "record_type": "CNAME", "address": "iamwork.in."}
+
+ # Add a TXT record (SPF)
+ {"action": "set_dns", "domain": "flowercore.io", "host_name": "@", "record_type": "TXT", "address": "v=spf1 include:_spf.google.com ~all"}
+
+ # Add an MX record
+ {"action": "set_dns", "domain": "flowercore.io", "host_name": "@", "record_type": "MX", "address": "mail.flowercore.io", "mx_pref": 10}
+
+ # Remove a record
+ {"action": "remove_dns", "domain": "flowercore.io", "host_name": "old", "record_type": "A"}
+
+ # Update Dynamic DNS
+ {"action": "update_ddns", "domain": "flowercore.io", "host_name": "home", "password": "your-ddns-password"}
+
+ # Get domain info (expiry, nameservers, whoisguard)
+ {"action": "get_info", "domain": "flowercore.io"}
+
+ # List SSL certificates
+ {"action": "list_ssl"}
+
+ # Set custom nameservers (e.g., Cloudflare)
+ {"action": "set_nameservers", "domain": "flowercore.io", "nameservers": "ns1.cloudflare.com, ns2.cloudflare.com"}
+ ```
+
+ ### Important Notes
+
+ - **DNS set/remove operations are safe**: They always fetch existing records first, then merge changes before writing back. No records are lost.
+ - **Client IP**: Auto-detected via ipify.org. Must be whitelisted in Namecheap API settings.
+ - **SLD/TLD splitting**: Handled automatically (e.g., `iamwork.in` -> SLD=`iamwork`, TLD=`in`).
+ - **Rate limits**: Namecheap allows ~50 requests/minute. Avoid rapid-fire calls.
+ """
+ network_diagrams.py: |
+ # Network Diagram Generator Tool
+ # Generates Graphviz DOT files and renders them to PNG/SVG/PDF.
+ # Supports network topology diagrams, Kubernetes architecture diagrams,
+ # infrastructure maps, sequence diagrams, and natural language descriptions.
+ # Uses Graphviz (dot CLI) for rendering; DOT source generation is pure Python.
+
+ import subprocess
+ import os
+ import re
+ import json
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class NetworkDiagrams(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Generate network diagrams using Graphviz DOT language.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options: "generate_dot", "render", "network_topology", "k8s_diagram",
+ "infrastructure_map", "sequence_diagram", "from_description"
+ title (str): Diagram title. Required for: generate_dot, network_topology, k8s_diagram,
+ infrastructure_map.
+ nodes (list): List of {id, label, shape, color} dicts. Required for: generate_dot.
+ edges (list): List of {from, to, label, style} dicts. Required for: generate_dot.
+ layout (str): Layout engine: "dot", "neato", "fdp", "circo". Default: "dot".
+ rankdir (str): Rank direction: "TB", "LR", "BT", "RL". Default: "TB".
+ dot_source (str): DOT code string or file path. Required for: render.
+ output_path (str): Output file path. Optional.
+ format (str): Output format: "png", "svg", "pdf". Default: "svg".
+ devices (list): Device list for network_topology.
+ Each: {name, type, ip, interfaces}.
+ connections (list): Connection list for network_topology.
+ Each: {from, to, interface, vlan, speed}.
+ namespaces (list): Kubernetes namespace list for k8s_diagram.
+ Each: {name, deployments, services, ingress}.
+ networks (list): Network list for infrastructure_map.
+ Each: {name, cidr, devices}.
+ participants (list): Participant names for sequence_diagram.
+ messages (list): Message list for sequence_diagram.
+ Each: {from, to, label, style}.
+ description (str): Natural language description. Required for: from_description.
+ diagram_type (str): Hint for from_description: "network", "k8s", "sequence", "flowchart".
+
+ Returns:
+ DOT source code and/or rendered diagram path, formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ title = self.args.get("title", "Diagram")
+ nodes = self.args.get("nodes", [])
+ edges = self.args.get("edges", [])
+ layout = self.args.get("layout", "dot")
+ rankdir = self.args.get("rankdir", "TB")
+ dot_source = self.args.get("dot_source", "")
+ output_path = self.args.get("output_path", "")
+ fmt = self.args.get("format", "svg").lower()
+ devices = self.args.get("devices", [])
+ connections = self.args.get("connections", [])
+ namespaces = self.args.get("namespaces", [])
+ networks = self.args.get("networks", [])
+ participants = self.args.get("participants", [])
+ messages = self.args.get("messages", [])
+ description = self.args.get("description", "")
+ diagram_type = self.args.get("diagram_type", "")
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = [
+ "generate_dot", "render", "network_topology", "k8s_diagram",
+ "infrastructure_map", "sequence_diagram", "from_description",
+ ]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ # Parse JSON strings if needed
+ nodes = _ensure_list(nodes)
+ edges = _ensure_list(edges)
+ devices = _ensure_list(devices)
+ connections = _ensure_list(connections)
+ namespaces = _ensure_list(namespaces)
+ networks = _ensure_list(networks)
+ participants = _ensure_list(participants)
+ messages = _ensure_list(messages)
+
+ if action == "generate_dot":
+ if not nodes:
+ return Response(message="Error: `nodes` is required for generate_dot action.", break_loop=False)
+ return Response(message=_generate_dot(title, nodes, edges, layout, rankdir), break_loop=False)
+
+ if action == "render":
+ if not dot_source:
+ return Response(message="Error: `dot_source` is required for render action.", break_loop=False)
+ return Response(message=_render(dot_source, output_path, fmt, layout), break_loop=False)
+
+ if action == "network_topology":
+ if not devices:
+ return Response(message="Error: `devices` is required for network_topology action.", break_loop=False)
+ return Response(message=_network_topology(title, devices, connections, layout, rankdir, output_path, fmt), break_loop=False)
+
+ if action == "k8s_diagram":
+ if not namespaces:
+ return Response(message="Error: `namespaces` is required for k8s_diagram action.", break_loop=False)
+ return Response(message=_k8s_diagram(title, namespaces, output_path, fmt), break_loop=False)
+
+ if action == "infrastructure_map":
+ if not networks:
+ return Response(message="Error: `networks` is required for infrastructure_map action.", break_loop=False)
+ return Response(message=_infrastructure_map(title, networks, connections, output_path, fmt), break_loop=False)
+
+ if action == "sequence_diagram":
+ if not participants:
+ return Response(message="Error: `participants` is required for sequence_diagram action.", break_loop=False)
+ if not messages:
+ return Response(message="Error: `messages` is required for sequence_diagram action.", break_loop=False)
+ return Response(message=_sequence_diagram(title, participants, messages, output_path, fmt), break_loop=False)
+
+ if action == "from_description":
+ if not description:
+ return Response(message="Error: `description` is required for from_description action.", break_loop=False)
+ return Response(message=_from_description(description, diagram_type, title, output_path, fmt), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented.", break_loop=False)
+
+
+ def _generate_dot(title: str, nodes: list, edges: list, layout: str, rankdir: str) -> str:
+ """Generate a Graphviz DOT file from nodes and edges."""
+ dot_lines = [
+ f'digraph "{_escape(title)}" {{',
+ f' rankdir={rankdir};',
+ f' label="{_escape(title)}";',
+ ' labelloc=t;',
+ ' fontname="Helvetica";',
+ ' fontsize=16;',
+ ' node [fontname="Helvetica", fontsize=11];',
+ ' edge [fontname="Helvetica", fontsize=9];',
+ '',
+ ]
+
+ for node in nodes:
+ node_id = node.get("id", "")
+ if not node_id:
+ continue
+ label = node.get("label", node_id)
+ shape = node.get("shape", "box")
+ color = node.get("color", "")
+ style = node.get("style", "")
+
+ attrs = [f'label="{_escape(label)}"', f'shape={shape}']
+ if color:
+ attrs.append(f'color="{color}"')
+ attrs.append(f'fillcolor="{color}"')
+ attrs.append('style="filled"')
+ if style:
+ attrs.append(f'style="{style}"')
+
+ dot_lines.append(f' "{_escape_id(node_id)}" [{", ".join(attrs)}];')
+
+ dot_lines.append('')
+
+ for edge in edges:
+ from_id = edge.get("from", "")
+ to_id = edge.get("to", "")
+ if not from_id or not to_id:
+ continue
+ label = edge.get("label", "")
+ style = edge.get("style", "")
+ color = edge.get("color", "")
+
+ attrs = []
+ if label:
+ attrs.append(f'label="{_escape(label)}"')
+ if style:
+ attrs.append(f'style={style}')
+ if color:
+ attrs.append(f'color="{color}"')
+
+ attr_str = f' [{", ".join(attrs)}]' if attrs else ""
+ dot_lines.append(f' "{_escape_id(from_id)}" -> "{_escape_id(to_id)}"{attr_str};')
+
+ dot_lines.append('}')
+
+ dot_code = "\n".join(dot_lines)
+
+ lines = [
+ "## Generated DOT Source",
+ "",
+ f"- **Title**: {title}",
+ f"- **Nodes**: {len(nodes)}",
+ f"- **Edges**: {len(edges)}",
+ f"- **Layout**: {layout}",
+ f"- **Direction**: {rankdir}",
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ]
+
+ return "\n".join(lines)
+
+
+ def _render(dot_source: str, output_path: str, fmt: str, layout: str) -> str:
+ """Render DOT source to an image file."""
+ # Determine if dot_source is a file path or inline DOT code
+ dot_code = ""
+ source_type = "inline"
+
+ if os.path.isfile(dot_source):
+ source_type = "file"
+ try:
+ with open(dot_source, "r", encoding="utf-8") as f:
+ dot_code = f.read()
+ except (IOError, UnicodeDecodeError) as e:
+ return f"Error reading DOT file: {e}"
+ else:
+ dot_code = dot_source
+
+ if not dot_code.strip():
+ return "Error: DOT source is empty."
+
+ # Default output path
+ if not output_path:
+ output_path = f"/tmp/diagram.{fmt}"
+
+ # Ensure output directory exists
+ out_dir = os.path.dirname(output_path)
+ if out_dir:
+ os.makedirs(out_dir, exist_ok=True)
+
+ # Try Graphviz dot CLI
+ cmd = [layout, f"-T{fmt}", "-o", output_path]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ input=dot_code,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except FileNotFoundError:
+ lines = [
+ "## Render Failed: Graphviz Not Found",
+ "",
+ "The `dot` command (Graphviz) is not installed.",
+ "",
+ "### Install Graphviz",
+ "",
+ "```bash",
+ "# Debian/Ubuntu",
+ "apt install graphviz",
+ "",
+ "# Alpine",
+ "apk add graphviz",
+ "",
+ "# macOS",
+ "brew install graphviz",
+ "```",
+ "",
+ "### DOT Source (save and render manually)",
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ]
+ return "\n".join(lines)
+ except subprocess.TimeoutExpired:
+ return "Error: Graphviz rendering timed out after 30 seconds."
+
+ if result.returncode != 0:
+ error_msg = result.stderr.strip()
+ lines = [
+ "## Render Failed",
+ "",
+ f"- **Exit code**: {result.returncode}",
+ f"- **Error**: {error_msg}",
+ "",
+ "### DOT Source",
+ "",
+ "```dot",
+ dot_code[:2000],
+ "```",
+ ]
+ return "\n".join(lines)
+
+ file_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
+
+ lines = [
+ "## Diagram Rendered",
+ "",
+ f"- **Output**: `{output_path}`",
+ f"- **Format**: {fmt.upper()}",
+ f"- **Layout**: {layout}",
+ f"- **File size**: {_format_size(file_size)}",
+ f"- **Source**: {source_type}",
+ ]
+
+ return "\n".join(lines)
+
+
+ def _network_topology(title: str, devices: list, connections: list,
+ layout: str, rankdir: str, output_path: str, fmt: str) -> str:
+ """Generate a network topology diagram."""
+ # Device type to shape/color mapping
+ device_styles = {
+ "router": {"shape": "diamond", "color": "#4A90D9", "icon": "R"},
+ "switch": {"shape": "box", "color": "#7ED321", "icon": "SW"},
+ "firewall": {"shape": "octagon", "color": "#D0021B", "icon": "FW"},
+ "server": {"shape": "box3d", "color": "#9B59B6", "icon": "SRV"},
+ "workstation": {"shape": "ellipse", "color": "#F5A623", "icon": "WS"},
+ "ap": {"shape": "invtriangle", "color": "#50E3C2", "icon": "AP"},
+ "cloud": {"shape": "cloud", "color": "#B8E986", "icon": ""},
+ "internet": {"shape": "cloud", "color": "#E8E8E8", "icon": ""},
+ "loadbalancer": {"shape": "parallelogram", "color": "#FF6B6B", "icon": "LB"},
+ "storage": {"shape": "cylinder", "color": "#C0A882", "icon": "STO"},
+ }
+
+ dot_lines = [
+ f'digraph "{_escape(title)}" {{',
+ f' rankdir={rankdir};',
+ f' label="{_escape(title)}";',
+ ' labelloc=t;',
+ ' fontname="Helvetica";',
+ ' fontsize=16;',
+ ' node [fontname="Helvetica", fontsize=10, style="filled,rounded"];',
+ ' edge [fontname="Helvetica", fontsize=8, dir=none];',
+ '',
+ ]
+
+ for device in devices:
+ name = device.get("name", "")
+ if not name:
+ continue
+ dev_type = device.get("type", "server").lower()
+ ip = device.get("ip", "")
+ interfaces = device.get("interfaces", [])
+
+ style = device_styles.get(dev_type, device_styles["server"])
+
+ # Build label with name, type icon, and IP
+ label_parts = [name]
+ if ip:
+ label_parts.append(ip)
+ if interfaces and isinstance(interfaces, list):
+ for iface in interfaces[:4]:
+ if isinstance(iface, str):
+ label_parts.append(iface)
+ elif isinstance(iface, dict):
+ iface_name = iface.get("name", "")
+ iface_ip = iface.get("ip", "")
+ if iface_name and iface_ip:
+ label_parts.append(f"{iface_name}: {iface_ip}")
+ elif iface_name:
+ label_parts.append(iface_name)
+
+ label = "\\n".join(label_parts)
+ node_id = _make_node_id(name)
+
+ dot_lines.append(
+ f' "{node_id}" [label="{_escape(label)}", '
+ f'shape={style["shape"]}, fillcolor="{style["color"]}", '
+ f'fontcolor="white"];'
+ )
+
+ dot_lines.append('')
+
+ for conn in connections:
+ from_name = conn.get("from", "")
+ to_name = conn.get("to", "")
+ if not from_name or not to_name:
+ continue
+ interface = conn.get("interface", "")
+ vlan = conn.get("vlan", "")
+ speed = conn.get("speed", "")
+
+ label_parts = []
+ if interface:
+ label_parts.append(interface)
+ if vlan:
+ label_parts.append(f"VLAN {vlan}")
+ if speed:
+ label_parts.append(speed)
+ label = "\\n".join(label_parts)
+
+ from_id = _make_node_id(from_name)
+ to_id = _make_node_id(to_name)
+
+ attrs = []
+ if label:
+ attrs.append(f'label="{_escape(label)}"')
+ attrs.append('penwidth=2')
+
+ attr_str = f' [{", ".join(attrs)}]' if attrs else ""
+ dot_lines.append(f' "{from_id}" -> "{to_id}"{attr_str};')
+
+ dot_lines.append('}')
+ dot_code = "\n".join(dot_lines)
+
+ # Try to render
+ rendered_info = ""
+ if output_path or _has_graphviz():
+ if not output_path:
+ output_path = f"/tmp/network-topology.{fmt}"
+ render_result = _render(dot_code, output_path, fmt, layout)
+ if "Rendered" in render_result:
+ rendered_info = f"\n\n{render_result}"
+
+ lines = [
+ "## Network Topology Diagram",
+ "",
+ f"- **Title**: {title}",
+ f"- **Devices**: {len(devices)}",
+ f"- **Connections**: {len(connections)}",
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ]
+
+ if rendered_info:
+ lines.append(rendered_info)
+
+ return "\n".join(lines)
+
+
+ def _k8s_diagram(title: str, namespaces: list, output_path: str, fmt: str) -> str:
+ """Generate a Kubernetes architecture diagram."""
+ dot_lines = [
+ f'digraph "{_escape(title)}" {{',
+ ' rankdir=TB;',
+ f' label="{_escape(title)}";',
+ ' labelloc=t;',
+ ' fontname="Helvetica";',
+ ' fontsize=16;',
+ ' compound=true;',
+ ' node [fontname="Helvetica", fontsize=10, style="filled,rounded"];',
+ ' edge [fontname="Helvetica", fontsize=8];',
+ '',
+ ' // Cluster colors',
+ ' // Namespace=lightblue, Deployment=lightgreen, Service=lightyellow, Ingress=lightsalmon',
+ '',
+ ]
+
+ ingress_nodes = []
+ service_to_deployment = []
+
+ for ns in namespaces:
+ ns_name = ns.get("name", "default")
+ deployments = ns.get("deployments", [])
+ services = ns.get("services", [])
+ ingress = ns.get("ingress", [])
+ ns_id = _make_node_id(ns_name)
+
+ dot_lines.append(f' subgraph "cluster_{ns_id}" {{')
+ dot_lines.append(f' label="namespace: {_escape(ns_name)}";')
+ dot_lines.append(' style="dashed,filled";')
+ dot_lines.append(' fillcolor="#E8F4FD";')
+ dot_lines.append(' color="#326CE5";')
+ dot_lines.append(' fontcolor="#326CE5";')
+ dot_lines.append('')
+
+ # Deployments -> Pods
+ for dep in deployments:
+ if isinstance(dep, str):
+ dep_name = dep
+ replicas = 1
+ else:
+ dep_name = dep.get("name", "")
+ replicas = dep.get("replicas", 1)
+
+ if not dep_name:
+ continue
+
+ dep_id = _make_node_id(f"{ns_name}_{dep_name}")
+ pod_label = f"{dep_name}\\n({replicas} replica{'s' if replicas != 1 else ''})"
+ dot_lines.append(
+ f' "{dep_id}" [label="{_escape(pod_label)}", '
+ f'shape=box, fillcolor="#C8E6C9", color="#388E3C"];'
+ )
+
+ dot_lines.append('')
+
+ # Services
+ for svc in services:
+ if isinstance(svc, str):
+ svc_name = svc
+ svc_type = "ClusterIP"
+ target = svc_name
+ else:
+ svc_name = svc.get("name", "")
+ svc_type = svc.get("type", "ClusterIP")
+ target = svc.get("target", svc_name)
+
+ if not svc_name:
+ continue
+
+ svc_id = _make_node_id(f"{ns_name}_svc_{svc_name}")
+ svc_label = f"{svc_name}\\n({svc_type})"
+ dot_lines.append(
+ f' "{svc_id}" [label="{_escape(svc_label)}", '
+ f'shape=hexagon, fillcolor="#FFF9C4", color="#F9A825"];'
+ )
+
+ # Service -> Deployment edge
+ target_id = _make_node_id(f"{ns_name}_{target}")
+ service_to_deployment.append((svc_id, target_id))
+
+ dot_lines.append('')
+
+ # Ingress
+ for ing in ingress:
+ if isinstance(ing, str):
+ ing_name = ing
+ host = ""
+ backend = ing_name
+ else:
+ ing_name = ing.get("name", "")
+ host = ing.get("host", "")
+ backend = ing.get("backend", ing_name)
+
+ if not ing_name:
+ continue
+
+ ing_id = _make_node_id(f"{ns_name}_ing_{ing_name}")
+ ing_label = f"{ing_name}"
+ if host:
+ ing_label += f"\\n{host}"
+ dot_lines.append(
+ f' "{ing_id}" [label="{_escape(ing_label)}", '
+ f'shape=invhouse, fillcolor="#FFCDD2", color="#C62828"];'
+ )
+
+ # Ingress -> Service edge
+ backend_svc_id = _make_node_id(f"{ns_name}_svc_{backend}")
+ ingress_nodes.append((ing_id, backend_svc_id))
+
+ dot_lines.append(' }')
+ dot_lines.append('')
+
+ # Add edges
+ if ingress_nodes or service_to_deployment:
+ dot_lines.append(' // Edges')
+ for ing_id, svc_id in ingress_nodes:
+ dot_lines.append(f' "{ing_id}" -> "{svc_id}" [style=bold, color="#C62828"];')
+ for svc_id, dep_id in service_to_deployment:
+ dot_lines.append(f' "{svc_id}" -> "{dep_id}" [style=dashed, color="#388E3C"];')
+
+ # Add internet/client node if there are ingress resources
+ if ingress_nodes:
+ dot_lines.append('')
+ dot_lines.append(' "internet" [label="Internet\\nClients", shape=cloud, '
+ 'fillcolor="#E0E0E0", style="filled"];')
+ for ing_id, _ in ingress_nodes:
+ dot_lines.append(f' "internet" -> "{ing_id}" [style=bold, color="#333333"];')
+
+ dot_lines.append('}')
+ dot_code = "\n".join(dot_lines)
+
+ # Try to render
+ rendered_info = ""
+ if output_path or _has_graphviz():
+ if not output_path:
+ output_path = f"/tmp/k8s-diagram.{fmt}"
+ render_result = _render(dot_code, output_path, fmt, "dot")
+ if "Rendered" in render_result:
+ rendered_info = f"\n\n{render_result}"
+
+ lines = [
+ "## Kubernetes Architecture Diagram",
+ "",
+ f"- **Title**: {title}",
+ f"- **Namespaces**: {len(namespaces)}",
+ "",
+ "### Legend",
+ "",
+ "| Shape | Color | Meaning |",
+ "|-------|-------|---------|",
+ "| Box | Green | Deployment/Pods |",
+ "| Hexagon | Yellow | Service |",
+ "| Inverted House | Red | Ingress |",
+ "| Cloud | Gray | Internet/Clients |",
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ]
+
+ if rendered_info:
+ lines.append(rendered_info)
+
+ return "\n".join(lines)
+
+
+ def _infrastructure_map(title: str, networks_list: list, connections: list,
+ output_path: str, fmt: str) -> str:
+ """Generate a hierarchical infrastructure map with network subnets as clusters."""
+ dot_lines = [
+ f'digraph "{_escape(title)}" {{',
+ ' rankdir=TB;',
+ f' label="{_escape(title)}";',
+ ' labelloc=t;',
+ ' fontname="Helvetica";',
+ ' fontsize=16;',
+ ' compound=true;',
+ ' node [fontname="Helvetica", fontsize=10, style="filled,rounded"];',
+ ' edge [fontname="Helvetica", fontsize=8, dir=none, penwidth=2];',
+ '',
+ ]
+
+ # Network colors (cycle through)
+ net_colors = [
+ ("#E3F2FD", "#1565C0"), # Blue
+ ("#E8F5E9", "#2E7D32"), # Green
+ ("#FFF3E0", "#E65100"), # Orange
+ ("#F3E5F5", "#6A1B9A"), # Purple
+ ("#FFEBEE", "#B71C1C"), # Red
+ ("#E0F7FA", "#00695C"), # Teal
+ ]
+
+ device_styles = {
+ "router": {"shape": "diamond", "color": "#4A90D9"},
+ "switch": {"shape": "box", "color": "#7ED321"},
+ "firewall": {"shape": "octagon", "color": "#D0021B"},
+ "server": {"shape": "box3d", "color": "#9B59B6"},
+ "workstation": {"shape": "ellipse", "color": "#F5A623"},
+ "ap": {"shape": "invtriangle", "color": "#50E3C2"},
+ }
+
+ for i, net in enumerate(networks_list):
+ net_name = net.get("name", f"network_{i}")
+ cidr = net.get("cidr", "")
+ net_devices = net.get("devices", [])
+ net_id = _make_node_id(net_name)
+
+ fill_color, border_color = net_colors[i % len(net_colors)]
+
+ net_label = net_name
+ if cidr:
+ net_label += f"\\n{cidr}"
+
+ dot_lines.append(f' subgraph "cluster_{net_id}" {{')
+ dot_lines.append(f' label="{_escape(net_label)}";')
+ dot_lines.append(f' style="filled,dashed";')
+ dot_lines.append(f' fillcolor="{fill_color}";')
+ dot_lines.append(f' color="{border_color}";')
+ dot_lines.append(f' fontcolor="{border_color}";')
+ dot_lines.append('')
+
+ for device in net_devices:
+ if isinstance(device, str):
+ dev_name = device
+ dev_type = "server"
+ dev_ip = ""
+ else:
+ dev_name = device.get("name", "")
+ dev_type = device.get("type", "server").lower()
+ dev_ip = device.get("ip", "")
+
+ if not dev_name:
+ continue
+
+ style = device_styles.get(dev_type, device_styles["server"])
+ dev_id = _make_node_id(f"{net_name}_{dev_name}")
+
+ label = dev_name
+ if dev_ip:
+ label += f"\\n{dev_ip}"
+
+ dot_lines.append(
+ f' "{dev_id}" [label="{_escape(label)}", '
+ f'shape={style["shape"]}, fillcolor="{style["color"]}", fontcolor="white"];'
+ )
+
+ dot_lines.append(' }')
+ dot_lines.append('')
+
+ # Inter-network connections
+ for conn in connections:
+ from_net = conn.get("from_net", conn.get("from", ""))
+ to_net = conn.get("to_net", conn.get("to", ""))
+ via = conn.get("via", "")
+ label = conn.get("label", "")
+
+ if not from_net or not to_net:
+ continue
+
+ # Connect via device or first device in each network
+ from_id = _find_device_id(from_net, via, networks_list)
+ to_id = _find_device_id(to_net, via, networks_list)
+
+ attrs = ['penwidth=3']
+ edge_label = via or label
+ if edge_label:
+ attrs.append(f'label="{_escape(edge_label)}"')
+
+ attr_str = f' [{", ".join(attrs)}]'
+ dot_lines.append(f' "{from_id}" -> "{to_id}"{attr_str};')
+
+ dot_lines.append('}')
+ dot_code = "\n".join(dot_lines)
+
+ # Try to render
+ rendered_info = ""
+ if output_path or _has_graphviz():
+ if not output_path:
+ output_path = f"/tmp/infrastructure-map.{fmt}"
+ render_result = _render(dot_code, output_path, fmt, "dot")
+ if "Rendered" in render_result:
+ rendered_info = f"\n\n{render_result}"
+
+ lines = [
+ "## Infrastructure Map",
+ "",
+ f"- **Title**: {title}",
+ f"- **Networks**: {len(networks_list)}",
+ f"- **Inter-network connections**: {len(connections)}",
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ]
+
+ if rendered_info:
+ lines.append(rendered_info)
+
+ return "\n".join(lines)
+
+
+ def _sequence_diagram(title: str, participants: list, messages_list: list,
+ output_path: str, fmt: str) -> str:
+ """Generate a sequence diagram approximation in DOT.
+
+ DOT is not ideal for sequence diagrams, but we can approximate one
+ using invisible edges for ordering and visible edges for messages.
+ """
+ dot_lines = [
+ f'digraph "{_escape(title)}" {{',
+ ' rankdir=TB;',
+ f' label="{_escape(title)}";',
+ ' labelloc=t;',
+ ' fontname="Helvetica";',
+ ' fontsize=16;',
+ ' node [fontname="Helvetica", fontsize=10];',
+ ' edge [fontname="Helvetica", fontsize=9];',
+ ' splines=ortho;',
+ '',
+ ]
+
+ # Create participant header nodes (rank=same)
+ dot_lines.append(' // Participant headers')
+ dot_lines.append(' { rank=same;')
+ for p in participants:
+ p_id = _make_node_id(p)
+ dot_lines.append(
+ f' "{p_id}" [label="{_escape(p)}", shape=box, '
+ f'style="filled", fillcolor="#E3F2FD", color="#1565C0"];'
+ )
+ dot_lines.append(' }')
+ dot_lines.append('')
+
+ # Create lifeline nodes for each message step
+ # Each step gets one node per participant (for vertical ordering)
+ step_count = len(messages_list)
+ for step in range(step_count):
+ dot_lines.append(f' // Step {step + 1}')
+ dot_lines.append(f' {{ rank=same;')
+ for p in participants:
+ p_id = _make_node_id(p)
+ step_id = f"{p_id}_s{step}"
+ dot_lines.append(
+ f' "{step_id}" [label="", shape=point, width=0.1, height=0.1];'
+ )
+ dot_lines.append(' }')
+
+ dot_lines.append('')
+
+ # Vertical lifeline edges (invisible, for ordering)
+ dot_lines.append(' // Lifelines (vertical ordering)')
+ for p in participants:
+ p_id = _make_node_id(p)
+ prev = p_id
+ for step in range(step_count):
+ step_id = f"{p_id}_s{step}"
+ dot_lines.append(f' "{prev}" -> "{step_id}" [style=dashed, color="#CCCCCC", arrowhead=none];')
+ prev = step_id
+ dot_lines.append('')
+
+ # Message edges
+ dot_lines.append(' // Messages')
+ style_map = {
+ "sync": 'style=bold, color="#333333"',
+ "async": 'style=dashed, color="#666666"',
+ "reply": 'style=dotted, color="#999999"',
+ "self": 'style=bold, color="#333333"',
+ }
+
+ for step, msg in enumerate(messages_list):
+ from_name = msg.get("from", "")
+ to_name = msg.get("to", "")
+ label = msg.get("label", "")
+ msg_style = msg.get("style", "sync")
+
+ if not from_name or not to_name:
+ continue
+
+ from_id = f"{_make_node_id(from_name)}_s{step}"
+ to_id = f"{_make_node_id(to_name)}_s{step}"
+ edge_style = style_map.get(msg_style, style_map["sync"])
+
+ dot_lines.append(
+ f' "{from_id}" -> "{to_id}" [label="{_escape(label)}", {edge_style}];'
+ )
+
+ dot_lines.append('}')
+ dot_code = "\n".join(dot_lines)
+
+ # Try to render
+ rendered_info = ""
+ if output_path or _has_graphviz():
+ if not output_path:
+ output_path = f"/tmp/sequence-diagram.{fmt}"
+ render_result = _render(dot_code, output_path, fmt, "dot")
+ if "Rendered" in render_result:
+ rendered_info = f"\n\n{render_result}"
+
+ lines = [
+ "## Sequence Diagram",
+ "",
+ f"- **Title**: {title}",
+ f"- **Participants**: {', '.join(participants)}",
+ f"- **Messages**: {len(messages_list)}",
+ "",
+ "### Message Flow",
+ "",
+ ]
+
+ for i, msg in enumerate(messages_list, 1):
+ style_icon = {"sync": "-->", "async": "~~>", "reply": "<--", "self": "->"}
+ arrow = style_icon.get(msg.get("style", "sync"), "-->")
+ lines.append(f"{i}. {msg.get('from', '?')} {arrow} {msg.get('to', '?')}: {msg.get('label', '')}")
+
+ lines.extend([
+ "",
+ "```dot",
+ dot_code,
+ "```",
+ ])
+
+ if rendered_info:
+ lines.append(rendered_info)
+
+ return "\n".join(lines)
+
+
+ def _from_description(description: str, diagram_type: str, title: str,
+ output_path: str, fmt: str) -> str:
+ """Generate a diagram from natural language description.
+
+ This is a best-effort parser that extracts device names, connections,
+ and types from plain English descriptions.
+ """
+ desc_lower = description.lower()
+
+ # Auto-detect diagram type if not specified
+ if not diagram_type:
+ if any(kw in desc_lower for kw in ["namespace", "pod", "deployment", "kubernetes", "k8s", "ingress"]):
+ diagram_type = "k8s"
+ elif any(kw in desc_lower for kw in ["sequence", "request", "response", "call", "return"]):
+ diagram_type = "sequence"
+ elif any(kw in desc_lower for kw in ["router", "switch", "firewall", "network", "subnet", "vlan"]):
+ diagram_type = "network"
+ else:
+ diagram_type = "flowchart"
+
+ if diagram_type == "flowchart":
+ return _parse_flowchart_description(description, title, output_path, fmt)
+
+ if diagram_type == "network":
+ return _parse_network_description(description, title, output_path, fmt)
+
+ if diagram_type == "k8s":
+ return _parse_k8s_description(description, title, output_path, fmt)
+
+ if diagram_type == "sequence":
+ return _parse_sequence_description(description, title, output_path, fmt)
+
+ return f"Error: Unknown diagram_type '{diagram_type}'. Options: network, k8s, sequence, flowchart"
+
+
+ def _parse_flowchart_description(description: str, title: str, output_path: str, fmt: str) -> str:
+ """Parse a description into a general flowchart."""
+ # Extract items that look like node names (capitalized words, quoted strings)
+ nodes = []
+ edges = []
+
+ # Look for "A connects to B", "A -> B", "A to B" patterns
+ connect_patterns = [
+ re.compile(r'"([^"]+)"\s*(?:->|-->|connects?\s+to|sends?\s+to|links?\s+to)\s*"([^"]+)"(?:\s*:\s*(.+))?', re.IGNORECASE),
+ re.compile(r'(\w[\w\s]*\w)\s+(?:->|-->|connects?\s+to|sends?\s+to|links?\s+to)\s+(\w[\w\s]*\w?)(?:\s*:\s*(.+))?', re.IGNORECASE),
+ ]
+
+ seen_nodes = set()
+ for pattern in connect_patterns:
+ for match in pattern.finditer(description):
+ from_name = match.group(1).strip()
+ to_name = match.group(2).strip()
+ label = (match.group(3) or "").strip() if match.lastindex >= 3 else ""
+
+ if from_name not in seen_nodes:
+ nodes.append({"id": _make_node_id(from_name), "label": from_name, "shape": "box", "color": ""})
+ seen_nodes.add(from_name)
+ if to_name not in seen_nodes:
+ nodes.append({"id": _make_node_id(to_name), "label": to_name, "shape": "box", "color": ""})
+ seen_nodes.add(to_name)
+
+ edges.append({"from": _make_node_id(from_name), "to": _make_node_id(to_name), "label": label})
+
+ if not nodes:
+ # Fallback: extract capitalized phrases as nodes
+ words = re.findall(r'\b([A-Z][\w]*(?:\s+[A-Z][\w]*)*)\b', description)
+ for w in words[:10]:
+ if w not in seen_nodes and len(w) > 1:
+ nodes.append({"id": _make_node_id(w), "label": w, "shape": "box", "color": ""})
+ seen_nodes.add(w)
+
+ dot_result = _generate_dot(title or "Flowchart", nodes, edges, "dot", "TB")
+
+ lines = [
+ "## Generated from Description",
+ "",
+ f"- **Type**: Flowchart",
+ f"- **Parsed nodes**: {len(nodes)}",
+ f"- **Parsed edges**: {len(edges)}",
+ "",
+ f"> {description[:200]}{'...' if len(description) > 200 else ''}",
+ "",
+ dot_result,
+ ]
+
+ return "\n".join(lines)
+
+
+ def _parse_network_description(description: str, title: str, output_path: str, fmt: str) -> str:
+ """Parse a network description into devices and connections."""
+ devices = []
+ connections = []
+
+ # Extract device-type patterns: "router R1", "switch SW1", "firewall FW1"
+ device_types = ["router", "switch", "firewall", "server", "workstation", "ap", "loadbalancer"]
+ type_pattern = re.compile(
+ rf'\b({"|".join(device_types)})\s+["\']?(\w[\w.-]*)["\']?',
+ re.IGNORECASE,
+ )
+
+ for match in type_pattern.finditer(description):
+ dev_type = match.group(1).lower()
+ dev_name = match.group(2)
+ devices.append({"name": dev_name, "type": dev_type})
+
+ # Extract IP addresses and associate with nearest device
+ ip_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:/\d{1,2})?)')
+ ips = ip_pattern.findall(description)
+
+ # Simple association: if an IP follows a device mention, assign it
+ for i, device in enumerate(devices):
+ if i < len(ips):
+ device["ip"] = ips[i]
+
+ # Extract connections: "A connects to B", "A -- B", "A <-> B"
+ conn_pattern = re.compile(
+ r'(\w[\w.-]*)\s+(?:connects?\s+to|--|<->|<-->|links?\s+to)\s+(\w[\w.-]*)',
+ re.IGNORECASE,
+ )
+ for match in conn_pattern.finditer(description):
+ connections.append({"from": match.group(1), "to": match.group(2)})
+
+ if not devices:
+ return f"## From Description\n\nCould not parse network devices from description.\n\n> {description[:300]}"
+
+ return _network_topology(title or "Network Topology", devices, connections, "dot", "TB", output_path, fmt)
+
+
+ def _parse_k8s_description(description: str, title: str, output_path: str, fmt: str) -> str:
+ """Parse a Kubernetes description."""
+ namespaces = []
+
+ # Extract namespace mentions
+ ns_pattern = re.compile(r'namespace\s+["\']?(\w[\w-]*)["\']?', re.IGNORECASE)
+ ns_names = ns_pattern.findall(description) or ["default"]
+
+ # Extract deployments
+ dep_pattern = re.compile(r'deployment\s+["\']?(\w[\w-]*)["\']?', re.IGNORECASE)
+ deployments = dep_pattern.findall(description)
+
+ # Extract services
+ svc_pattern = re.compile(r'service\s+["\']?(\w[\w-]*)["\']?', re.IGNORECASE)
+ services = svc_pattern.findall(description)
+
+ # Extract ingress
+ ing_pattern = re.compile(r'ingress\s+["\']?(\w[\w-]*)["\']?', re.IGNORECASE)
+ ingresses = ing_pattern.findall(description)
+
+ # Build namespace structure
+ for ns_name in ns_names:
+ namespaces.append({
+ "name": ns_name,
+ "deployments": deployments if ns_name == ns_names[0] else [],
+ "services": services if ns_name == ns_names[0] else [],
+ "ingress": ingresses if ns_name == ns_names[0] else [],
+ })
+
+ return _k8s_diagram(title or "Kubernetes Architecture", namespaces, output_path, fmt)
+
+
+ def _parse_sequence_description(description: str, title: str, output_path: str, fmt: str) -> str:
+ """Parse a sequence diagram description."""
+ participants = []
+ messages_list = []
+
+ # Extract "A sends X to B", "A calls B with X", "A -> B: X"
+ msg_patterns = [
+ re.compile(r'(\w[\w\s]*\w)\s+(?:sends?|calls?|requests?)\s+(?:(\w[\w\s]*\w)\s+(?:to|from)\s+)?(\w[\w\s]*\w)', re.IGNORECASE),
+ re.compile(r'(\w+)\s*->\s*(\w+)\s*:\s*(.+?)(?:\.|$)', re.IGNORECASE),
+ ]
+
+ seen_participants = set()
+
+ # Try arrow pattern first
+ arrow_matches = re.findall(r'(\w+)\s*->\s*(\w+)\s*:\s*([^.\n]+)', description)
+ for from_name, to_name, label in arrow_matches:
+ if from_name not in seen_participants:
+ participants.append(from_name)
+ seen_participants.add(from_name)
+ if to_name not in seen_participants:
+ participants.append(to_name)
+ seen_participants.add(to_name)
+ messages_list.append({"from": from_name, "to": to_name, "label": label.strip()})
+
+ if not messages_list:
+ # Try natural language patterns
+ send_matches = re.findall(
+ r'(\w[\w]*)\s+(?:sends?|calls?|requests?)\s+"?([^"]+)"?\s+(?:to|from)\s+(\w[\w]*)',
+ description, re.IGNORECASE,
+ )
+ for from_name, label, to_name in send_matches:
+ if from_name not in seen_participants:
+ participants.append(from_name)
+ seen_participants.add(from_name)
+ if to_name not in seen_participants:
+ participants.append(to_name)
+ seen_participants.add(to_name)
+ messages_list.append({"from": from_name, "to": to_name, "label": label.strip()})
+
+ if not participants:
+ return f"## From Description\n\nCould not parse sequence diagram from description.\n\n> {description[:300]}"
+
+ return _sequence_diagram(title or "Sequence Diagram", participants, messages_list, output_path, fmt)
+
+
+ # --------------------------------------------------------------------------
+ # Helper functions
+ # --------------------------------------------------------------------------
+
+ def _escape(text: str) -> str:
+ """Escape special characters for DOT labels."""
+ return text.replace("\\", "\\\\").replace('"', '\\"')
+
+
+ def _escape_id(node_id: str) -> str:
+ """Escape a node ID for DOT."""
+ return node_id.replace('"', '\\"')
+
+
+ def _make_node_id(name: str) -> str:
+ """Convert a name to a safe DOT node identifier."""
+ safe = re.sub(r'[^a-zA-Z0-9_]', '_', name)
+ return safe.lower()
+
+
+ def _ensure_list(value) -> list:
+ """Ensure a value is a list, parsing JSON strings if needed."""
+ if isinstance(value, list):
+ return value
+ if isinstance(value, str):
+ if not value:
+ return []
+ try:
+ parsed = json.loads(value)
+ if isinstance(parsed, list):
+ return parsed
+ except json.JSONDecodeError:
+ pass
+ return []
+
+
+ def _has_graphviz() -> bool:
+ """Check if Graphviz (dot) is available."""
+ try:
+ result = subprocess.run(
+ ["which", "dot"],
+ capture_output=True, text=True, timeout=5,
+ )
+ return result.returncode == 0
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False
+
+
+ def _find_device_id(net_name: str, via: str, networks_list: list) -> str:
+ """Find a device ID in a network for inter-network edges."""
+ # If a 'via' device is specified, try to find it
+ if via:
+ for net in networks_list:
+ for device in net.get("devices", []):
+ dev_name = device.get("name", device) if isinstance(device, dict) else device
+ if dev_name == via:
+ return _make_node_id(f"{net.get('name', '')}_{dev_name}")
+
+ # Fall back to first device in the named network
+ for net in networks_list:
+ if net.get("name", "") == net_name:
+ net_devices = net.get("devices", [])
+ if net_devices:
+ first = net_devices[0]
+ dev_name = first.get("name", first) if isinstance(first, dict) else first
+ return _make_node_id(f"{net_name}_{dev_name}")
+
+ return _make_node_id(net_name)
+
+
+ def _format_size(size_bytes: int) -> str:
+ """Format a byte count as a human-readable size."""
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ elif size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ elif size_bytes < 1024 * 1024 * 1024:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ else:
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## Network Diagram Generator Usage
+
+ Generate Graphviz DOT diagrams for networks, Kubernetes, infrastructure, and more.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `generate_dot` | Generate DOT from nodes/edges | `nodes` |
+ | `render` | Render DOT to image (PNG/SVG/PDF) | `dot_source` |
+ | `network_topology` | Network topology diagram | `devices` |
+ | `k8s_diagram` | Kubernetes architecture diagram | `namespaces` |
+ | `infrastructure_map` | Multi-network infrastructure map | `networks` |
+ | `sequence_diagram` | Sequence diagram (DOT approximation) | `participants`, `messages` |
+ | `from_description` | Generate from natural language | `description` |
+
+ ### Common Optional Args
+
+ | Arg | Default | Description |
+ |-----|---------|-------------|
+ | `title` | "Diagram" | Diagram title |
+ | `layout` | `dot` | Graphviz layout: dot, neato, fdp, circo |
+ | `rankdir` | `TB` | Direction: TB, LR, BT, RL |
+ | `output_path` | `/tmp/diagram.svg` | Output file for rendered image |
+ | `format` | `svg` | Output format: png, svg, pdf |
+
+ ### Examples
+
+ ```python
+ # Simple node/edge diagram
+ {"action": "generate_dot", "title": "My System",
+ "nodes": [
+ {"id": "web", "label": "Web Server", "shape": "box", "color": "#4A90D9"},
+ {"id": "db", "label": "Database", "shape": "cylinder", "color": "#9B59B6"}
+ ],
+ "edges": [
+ {"from": "web", "to": "db", "label": "SQL"}
+ ]}
+
+ # Network topology
+ {"action": "network_topology", "title": "Office Network",
+ "devices": [
+ {"name": "fw01", "type": "firewall", "ip": "10.0.0.1"},
+ {"name": "sw01", "type": "switch", "ip": "10.0.0.2"},
+ {"name": "srv01", "type": "server", "ip": "10.0.0.10"}
+ ],
+ "connections": [
+ {"from": "fw01", "to": "sw01", "speed": "10G"},
+ {"from": "sw01", "to": "srv01", "vlan": "100"}
+ ]}
+
+ # Kubernetes diagram
+ {"action": "k8s_diagram", "title": "FlowerCore K8s",
+ "namespaces": [{
+ "name": "fc-system",
+ "deployments": [
+ {"name": "signage-web", "replicas": 2},
+ {"name": "mysql-web", "replicas": 1}
+ ],
+ "services": [
+ {"name": "signage-svc", "type": "ClusterIP", "target": "signage-web"},
+ {"name": "mysql-svc", "type": "ClusterIP", "target": "mysql-web"}
+ ],
+ "ingress": [
+ {"name": "main-ingress", "host": "flowercore.io", "backend": "signage-svc"}
+ ]
+ }]}
+
+ # Infrastructure map
+ {"action": "infrastructure_map", "title": "Corp Infrastructure",
+ "networks": [
+ {"name": "DMZ", "cidr": "10.0.1.0/24",
+ "devices": [{"name": "fw01", "type": "firewall"}, {"name": "web01", "type": "server"}]},
+ {"name": "Internal", "cidr": "10.0.2.0/24",
+ "devices": [{"name": "app01", "type": "server"}, {"name": "db01", "type": "server"}]}
+ ],
+ "connections": [
+ {"from_net": "DMZ", "to_net": "Internal", "via": "fw01"}
+ ]}
+
+ # Sequence diagram
+ {"action": "sequence_diagram", "title": "Login Flow",
+ "participants": ["Client", "API", "Auth", "DB"],
+ "messages": [
+ {"from": "Client", "to": "API", "label": "POST /login"},
+ {"from": "API", "to": "Auth", "label": "validate_token()"},
+ {"from": "Auth", "to": "DB", "label": "SELECT user"},
+ {"from": "DB", "to": "Auth", "label": "user record", "style": "reply"},
+ {"from": "Auth", "to": "API", "label": "JWT token", "style": "reply"},
+ {"from": "API", "to": "Client", "label": "200 OK", "style": "reply"}
+ ]}
+
+ # From natural language
+ {"action": "from_description",
+ "description": "Client -> API: login request. API -> Database: query user. Database -> API: user data. API -> Client: JWT token.",
+ "diagram_type": "sequence"}
+
+ # Render existing DOT source
+ {"action": "render", "dot_source": "/tmp/my-diagram.dot", "format": "png"}
+ ```
+
+ ### Device Types (network_topology)
+
+ | Type | Shape | Color |
+ |------|-------|-------|
+ | router | Diamond | Blue |
+ | switch | Box | Green |
+ | firewall | Octagon | Red |
+ | server | 3D Box | Purple |
+ | workstation | Ellipse | Orange |
+ | ap | Inverted Triangle | Teal |
+ | cloud | Cloud | Light Green |
+ | internet | Cloud | Gray |
+ | loadbalancer | Parallelogram | Salmon |
+ | storage | Cylinder | Tan |
+
+ ### Requirements
+
+ - **DOT generation**: Pure Python, no external dependencies
+ - **Rendering**: Requires Graphviz (`apt install graphviz`)
+ - All actions return DOT source even if rendering is unavailable
+ """
+ notes_query.py: |
+ # FlowerCore Notes Knowledge Query Tool
+ # Reads and queries the FlowerCore.Notes repository for project context.
+ # Provides access to documentation, test counts, sprint history, backlog, and project plans.
+
+ import subprocess
+ import os
+ import re
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class NotesQuery(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Query the FlowerCore.Notes knowledge base.
+
+ Args (via self.args):
+ action (str): The action to perform. Required.
+ Options: "search_docs", "get_test_counts", "list_sprints",
+ "get_backlog", "get_project_plan", "read_file",
+ "list_docs", "get_services", "get_memory"
+ pattern (str): Search pattern (required for search_docs).
+ file_path (str): Relative file path (required for read_file).
+ service (str): Service name filter for get_test_counts.
+ limit (int): Maximum results. Default: 30.
+
+ Returns:
+ Response with knowledge base data formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ pattern = self.args.get("pattern", "")
+ file_path = self.args.get("file_path", "")
+ service = self.args.get("service", "")
+ limit = self.args.get("limit", 30)
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ notes_root = Path("/a0/work/repos/FlowerCore/FlowerCore.Notes")
+ if not notes_root.exists():
+ return Response(message=f"Error: FlowerCore.Notes path does not exist: {notes_root}", break_loop=False)
+
+ # Validate action
+ valid_actions = [
+ "search_docs", "get_test_counts", "list_sprints", "get_backlog",
+ "get_project_plan", "read_file", "list_docs", "get_services", "get_memory",
+ ]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ # Execute action
+ if action == "search_docs":
+ if not pattern:
+ return Response(message="Error: pattern is required for search_docs action", break_loop=False)
+ return Response(message=_search_docs(notes_root, pattern, limit), break_loop=False)
+
+ if action == "get_test_counts":
+ return Response(message=_get_test_counts(notes_root, service), break_loop=False)
+
+ if action == "list_sprints":
+ return Response(message=_list_sprints(notes_root, limit), break_loop=False)
+
+ if action == "get_backlog":
+ return Response(message=_get_backlog(notes_root), break_loop=False)
+
+ if action == "get_project_plan":
+ return Response(message=_get_project_plan(notes_root), break_loop=False)
+
+ if action == "read_file":
+ if not file_path:
+ return Response(message="Error: file_path is required for read_file action", break_loop=False)
+ return Response(message=_read_file(notes_root, file_path), break_loop=False)
+
+ if action == "list_docs":
+ return Response(message=_list_docs(notes_root), break_loop=False)
+
+ if action == "get_services":
+ return Response(message=_get_services(notes_root), break_loop=False)
+
+ if action == "get_memory":
+ return Response(message=_get_memory(notes_root), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented", break_loop=False)
+
+
+ def _search_docs(notes_root: Path, pattern: str, limit: int) -> str:
+ """Search across all documentation files."""
+ docs_path = notes_root / "docs"
+ if not docs_path.exists():
+ return f"Error: docs directory does not exist: {docs_path}"
+
+ cmd = [
+ "rg", "-n", "--heading", "-i",
+ "-m", str(limit),
+ "--glob", "*.md",
+ "--glob", "*.html",
+ pattern,
+ str(docs_path),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except FileNotFoundError:
+ # Fallback to grep
+ return _search_docs_grep(docs_path, pattern, limit)
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 30 seconds"
+
+ if result.returncode == 1:
+ return f"## Documentation Search\n\nNo matches found for: `{pattern}`"
+ if result.returncode > 1:
+ return f"Error: ripgrep failed with code {result.returncode}"
+
+ lines = [
+ f"## Documentation Search Results",
+ "",
+ f"- **Pattern**: `{pattern}`",
+ "",
+ f"```",
+ ]
+
+ output_lines = result.stdout.strip().split("\n")
+ for line in output_lines[:limit * 3]:
+ # Shorten paths
+ display_line = line.replace(str(notes_root) + "/", "")
+ lines.append(display_line)
+
+ if len(output_lines) > limit * 3:
+ lines.append(f"... ({len(output_lines) - limit * 3} more lines)")
+
+ lines.append(f"```")
+ return "\n".join(lines)
+
+
+ def _search_docs_grep(docs_path: Path, pattern: str, limit: int) -> str:
+ """Fallback grep-based search."""
+ cmd = [
+ "grep", "-r", "-n", "-i",
+ "--include=*.md",
+ "--include=*.html",
+ pattern,
+ str(docs_path),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: Search failed (neither rg nor grep available)"
+
+ if result.returncode == 1:
+ return f"## Documentation Search\n\nNo matches found for: `{pattern}`"
+
+ lines = [f"## Documentation Search Results", "", f"- **Pattern**: `{pattern}`", "", f"```"]
+ output_lines = result.stdout.strip().split("\n")[:limit]
+ for line in output_lines:
+ lines.append(line.replace(str(docs_path) + "/", "docs/"))
+ lines.append(f"```")
+ return "\n".join(lines)
+
+
+ def _get_test_counts(notes_root: Path, service: str) -> str:
+ """Parse test counts from MEMORY.md equivalent files."""
+ # Try MEMORY.md first (Claude Code memory file)
+ memory_file = notes_root / ".." / ".." / ".." / ".." / "home" / "stoltz" / ".claude" / "projects" / "-mnt-d-git-FlowerCore-FlowerCore-Notes" / "memory" / "MEMORY.md"
+
+ # Fallback: check for MEMORY.md in notes root
+ if not memory_file.exists():
+ memory_file = notes_root / "MEMORY.md"
+
+ if not memory_file.exists():
+ return "Error: MEMORY.md not found. Cannot retrieve test counts."
+
+ try:
+ with open(memory_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading MEMORY.md: {e}"
+
+ # Extract test count table
+ test_table_pattern = re.compile(
+ r"\|\s*Service\s*\|\s*Tests\s*\|.*?\n\|[-\s|]+\|\n(.*?)(?:\n\n|\n\|[^-]|\Z)",
+ re.DOTALL,
+ )
+ match = test_table_pattern.search(content)
+
+ if not match:
+ return "Error: Test count table not found in MEMORY.md"
+
+ table_rows = match.group(1).strip().split("\n")
+ services = []
+ total = 0
+
+ for row in table_rows:
+ row_match = re.match(r"\|\s*([^|]+?)\s*\|\s*([0-9,]+)\s*\|", row)
+ if row_match:
+ svc_name = row_match.group(1).strip()
+ svc_tests = int(row_match.group(2).replace(",", ""))
+ services.append({"name": svc_name, "tests": svc_tests})
+ total += svc_tests
+
+ # Filter by service if requested
+ if service:
+ services = [s for s in services if service.lower() in s["name"].lower()]
+ if not services:
+ return f"Error: Service '{service}' not found in test counts"
+
+ lines = [
+ f"## FlowerCore Test Counts",
+ "",
+ f"**Total tests**: {total:,}",
+ "",
+ "| Service | Tests |",
+ "|---------|-------|",
+ ]
+
+ for svc in services:
+ lines.append(f"| {svc['name']} | {svc['tests']:,} |")
+
+ return "\n".join(lines)
+
+
+ def _list_sprints(notes_root: Path, limit: int) -> str:
+ """Parse sprint history from MEMORY.md."""
+ memory_file = notes_root / ".." / ".." / ".." / ".." / "home" / "stoltz" / ".claude" / "projects" / "-mnt-d-git-FlowerCore-FlowerCore-Notes" / "memory" / "MEMORY.md"
+
+ if not memory_file.exists():
+ memory_file = notes_root / "MEMORY.md"
+
+ if not memory_file.exists():
+ return "Error: MEMORY.md not found"
+
+ try:
+ with open(memory_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading MEMORY.md: {e}"
+
+ # Find sprint sections (pattern: "## Sprint N COMPLETE" or "## X Sprint COMPLETE")
+ sprint_pattern = re.compile(r"^## (.+? Sprint COMPLETE.+?)$", re.MULTILINE)
+ sprints = sprint_pattern.findall(content)
+
+ if not sprints:
+ return "## Sprint History\n\nNo completed sprints found in MEMORY.md"
+
+ lines = [
+ f"## Recent Sprints ({len(sprints)})",
+ "",
+ ]
+
+ for sprint in sprints[-limit:]:
+ lines.append(f"- {sprint}")
+
+ return "\n".join(lines)
+
+
+ def _get_backlog(notes_root: Path) -> str:
+ """Read feature backlog."""
+ backlog_file = notes_root / "docs" / "feature-backlog.md"
+
+ if not backlog_file.exists():
+ return f"Error: feature-backlog.md not found at {backlog_file}"
+
+ try:
+ with open(backlog_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading feature-backlog.md: {e}"
+
+ # Truncate to first 3000 chars to avoid flooding
+ if len(content) > 3000:
+ content = content[:3000] + "\n\n... (truncated, use read_file for full content)"
+
+ return f"## Feature Backlog\n\n{content}"
+
+
+ def _get_project_plan(notes_root: Path) -> str:
+ """Parse project-plan.html for text summary."""
+ plan_file = notes_root / "project-plan.html"
+
+ if not plan_file.exists():
+ return f"Error: project-plan.html not found at {plan_file}"
+
+ try:
+ with open(plan_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading project-plan.html: {e}"
+
+ # Extract text between tags (crude HTML parsing)
+ body_match = re.search(r"]*>(.*?)", content, re.DOTALL | re.IGNORECASE)
+ if not body_match:
+ return "Error: Could not parse project-plan.html body"
+
+ body_text = body_match.group(1)
+
+ # Strip HTML tags
+ text = re.sub(r"", "", body_text, flags=re.DOTALL)
+ text = re.sub(r"", "", text, flags=re.DOTALL)
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = re.sub(r"\s+", " ", text).strip()
+
+ # Truncate
+ if len(text) > 2000:
+ text = text[:2000] + "\n\n... (truncated, use read_file for full HTML)"
+
+ return f"## Project Plan Summary\n\n{text}"
+
+
+ def _read_file(notes_root: Path, file_path: str) -> str:
+ """Read a specific file from the Notes repo."""
+ full_path = notes_root / file_path
+
+ if not full_path.exists():
+ return f"Error: File not found: {full_path}"
+
+ # Security check: prevent directory traversal
+ try:
+ full_path.resolve().relative_to(notes_root.resolve())
+ except ValueError:
+ return f"Error: File path outside Notes repo: {file_path}"
+
+ try:
+ with open(full_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading file: {e}"
+ except UnicodeDecodeError:
+ return f"Error: File appears to be binary: {file_path}"
+
+ # Truncate large files
+ if len(content) > 5000:
+ content = content[:5000] + "\n\n... (truncated after 5000 chars)"
+
+ return f"## File: {file_path}\n\n```\n{content}\n```"
+
+
+ def _list_docs(notes_root: Path) -> str:
+ """List all documentation files."""
+ docs_path = notes_root / "docs"
+
+ if not docs_path.exists():
+ return f"Error: docs directory does not exist: {docs_path}"
+
+ try:
+ result = subprocess.run(
+ ["find", str(docs_path), "-type", "f", "-name", "*.md", "-o", "-name", "*.html"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ files = result.stdout.strip().split("\n")
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: find command failed"
+
+ files = [f.replace(str(notes_root) + "/", "") for f in files if f]
+ files.sort()
+
+ lines = [
+ f"## Documentation Files ({len(files)})",
+ "",
+ ]
+
+ for f in files:
+ lines.append(f"- `{f}`")
+
+ return "\n".join(lines)
+
+
+ def _get_services(notes_root: Path) -> str:
+ """Extract service information from test counts."""
+ # Reuse test count parser
+ test_data = _get_test_counts(notes_root, "")
+
+ if test_data.startswith("Error:"):
+ return test_data
+
+ return test_data.replace("## FlowerCore Test Counts", "## FlowerCore Services")
+
+
+ def _get_memory(notes_root: Path) -> str:
+ """Read the full MEMORY.md file."""
+ memory_file = notes_root / ".." / ".." / ".." / ".." / "home" / "stoltz" / ".claude" / "projects" / "-mnt-d-git-FlowerCore-FlowerCore-Notes" / "memory" / "MEMORY.md"
+
+ if not memory_file.exists():
+ memory_file = notes_root / "MEMORY.md"
+
+ if not memory_file.exists():
+ return "Error: MEMORY.md not found"
+
+ try:
+ with open(memory_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except IOError as e:
+ return f"Error reading MEMORY.md: {e}"
+
+ # Truncate to avoid flooding (keep first 4000 chars)
+ if len(content) > 4000:
+ content = content[:4000] + "\n\n... (truncated, full file is longer)"
+
+ return f"## FlowerCore Memory\n\n```markdown\n{content}\n```"
+
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## FlowerCore Notes Query Tool
+
+ Query the FlowerCore.Notes knowledge base for project context and documentation.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `search_docs` | Search all documentation | `pattern` |
+ | `get_test_counts` | Get test counts by service | Optional: `service` |
+ | `list_sprints` | List completed sprints | None |
+ | `get_backlog` | Read feature backlog | None |
+ | `get_project_plan` | Get project plan summary | None |
+ | `read_file` | Read a specific file | `file_path` |
+ | `list_docs` | List all doc files | None |
+ | `get_services` | List all services | None |
+ | `get_memory` | Read MEMORY.md | None |
+
+ ### Examples
+
+ ```python
+ # Search for "traffic light"
+ {"action": "search_docs", "pattern": "traffic light"}
+
+ # Get all test counts
+ {"action": "get_test_counts"}
+
+ # Get test counts for Signage service
+ {"action": "get_test_counts", "service": "Signage"}
+
+ # List recent sprints
+ {"action": "list_sprints", "limit": 5}
+
+ # Read a specific file
+ {"action": "read_file", "file_path": "docs/digital-signage.html"}
+
+ # Get feature backlog
+ {"action": "get_backlog"}
+ ```
+
+ ### File Paths
+
+ All file paths are relative to `/a0/work/repos/FlowerCore/FlowerCore.Notes/`.
+ For example: `"docs/feature-backlog.md"` not `"/a0/work/repos/.../docs/feature-backlog.md"`.
+ """
+ ollama_model_switch.py: |
+ # Ollama Model Switch Tool
+ # Switches the active Ollama model for specific tasks.
+ # Preloads a model into GPU memory by sending a minimal generate request with keep_alive.
+ # Useful for switching between code generation, vision, reasoning, and utility models.
+
+ import subprocess
+ import json
+
+ from python.helpers.tool import Tool, Response
+
+
+ class OllamaModelSwitch(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Switch or preload an Ollama model for a specific task.
+
+ This tool loads a model into GPU VRAM by sending a keep_alive request.
+ The AMD R9700 32GB VRAM can hold 3-4 models simultaneously.
+ Loading a new model may evict the least-recently-used one.
+
+ Args (via self.args):
+ model (str): The Ollama model name to load. Required.
+ Available models:
+ - qwen3:32b (chat brain, JSON tool-call)
+ - qwen2.5:3b (quick utility)
+ - qwen3-coder:30b (advanced code gen, 75 tok/s)
+ - devstral:24b (agentic coding specialist)
+ - phi4:14b (.NET reasoning, architecture)
+ - granite3.1-dense:8b (structured JSON/YAML)
+ - gemma3:27b (vision + text, browser model)
+ - qwen3-vl:8b (fast lightweight vision)
+ - mistral:7b (fast summarization, 80 tok/s)
+ - deepseek-r1:32b (deep reasoning)
+ - deepseek-ocr (document OCR)
+ - translategemma:12b (translation)
+ - nomic-embed-text (embeddings)
+ task (str): Description of what the model will be used for. Logged for reference.
+ keep_alive (str): How long to keep the model loaded. Default: "10m".
+ Use "0" to unload immediately, "-1" to keep indefinitely.
+ api_base (str): Ollama API base URL. Default: "http://host.docker.internal:11434".
+
+ Returns:
+ Response with status of the model switch including VRAM usage info.
+ """
+ model = self.args.get("model", "")
+ task = self.args.get("task", "general")
+ keep_alive = self.args.get("keep_alive", "10m")
+ api_base = self.args.get("api_base", "http://host.docker.internal:11434")
+
+ if not model:
+ return Response(message=_list_available_models(), break_loop=False)
+
+ # Model metadata for validation and recommendations
+ model_info = _get_model_info()
+
+ # Validate the model name
+ known_model = model_info.get(model)
+ if not known_model:
+ # Check if it is a partial match
+ partial_matches = [k for k in model_info if model in k]
+ if partial_matches:
+ return Response(
+ message=(
+ f"Model `{model}` not found exactly. Did you mean one of these?\n"
+ + "\n".join(f"- `{m}` -- {model_info[m]['role']}" for m in partial_matches)
+ ),
+ break_loop=False,
+ )
+ return Response(
+ message=(
+ f"Model `{model}` is not in the known inventory. "
+ f"It may still work if installed. Proceeding with load attempt.\n"
+ f"Run without a model argument to see all available models."
+ ),
+ break_loop=False,
+ )
+
+ # Check VRAM budget
+ vram_warning = ""
+ if known_model and known_model.get("vram_gb", 0) > 24:
+ vram_warning = (
+ f"\nNote: `{model}` uses ~{known_model['vram_gb']}GB VRAM. "
+ f"This may limit how many other models fit in the R9700 32GB."
+ )
+
+ # Step 1: Check which models are currently loaded
+ loaded_models = _get_loaded_models(api_base)
+
+ # Step 2: Load the requested model via a minimal generate request with keep_alive
+ load_result = _load_model(model, keep_alive, api_base)
+
+ # Step 3: Format the result
+ lines = [
+ f"## Model Switch",
+ f"",
+ f"- **Model**: `{model}`",
+ f"- **Task**: {task}",
+ f"- **Keep alive**: {keep_alive}",
+ ]
+
+ if known_model:
+ lines.append(f"- **Size**: ~{known_model.get('vram_gb', '?')}GB VRAM")
+ lines.append(f"- **Role**: {known_model['role']}")
+ if known_model.get("speed"):
+ lines.append(f"- **Speed**: {known_model['speed']}")
+
+ lines.append(f"- **Status**: {load_result}")
+
+ if loaded_models:
+ lines.append(f"")
+ lines.append(f"### Previously Loaded Models")
+ for lm in loaded_models:
+ lines.append(f"- `{lm['name']}` (size: {lm.get('size', 'unknown')})")
+
+ if vram_warning:
+ lines.append(f"")
+ lines.append(vram_warning)
+
+ # Provide task-specific recommendations
+ recommendation = _get_recommendation(model, task)
+ if recommendation:
+ lines.append(f"")
+ lines.append(f"### Recommendation")
+ lines.append(recommendation)
+
+ return Response(message="\n".join(lines), break_loop=False)
+
+
+ def _get_model_info() -> dict:
+ """Return metadata for known Ollama models."""
+ return {
+ "qwen2.5:3b": {
+ "role": "Quick utility tasks",
+ "vram_gb": 4.3,
+ "speed": "~190 tok/s",
+ },
+ "mistral:7b": {
+ "role": "Fast summarization",
+ "vram_gb": 10.8,
+ "speed": "~110 tok/s",
+ },
+ "granite3.1-dense:8b": {
+ "role": "Structured JSON/YAML output, tool calling",
+ "vram_gb": 13.9,
+ "speed": "~92 tok/s",
+ },
+ "deepseek-r1:8b": {
+ "role": "Reasoning (compact)",
+ "vram_gb": 10.2,
+ "speed": "~73 tok/s",
+ },
+ "qwen3-vl:8b": {
+ "role": "Fast lightweight vision (screenshots, quick analysis)",
+ "vram_gb": 11.7,
+ "speed": "~76 tok/s",
+ },
+ "deepseek-ocr": {
+ "role": "Document OCR",
+ "vram_gb": 10.3,
+ "speed": "~167 tok/s",
+ },
+ "translategemma:12b": {
+ "role": "Translation (55 languages)",
+ "vram_gb": 11.8,
+ "speed": "~54 tok/s",
+ },
+ "phi4:14b": {
+ "role": ".NET-focused reasoning and architecture review",
+ "vram_gb": 14.4,
+ "speed": "~60 tok/s",
+ },
+ "devstral:24b": {
+ "role": "Agentic coding specialist (Mistral)",
+ "vram_gb": 15,
+ "speed": "needs ReBAR",
+ },
+ "gemma3:27b": {
+ "role": "Vision + text analysis, browser model",
+ "vram_gb": 19,
+ "speed": "needs ReBAR",
+ },
+ "qwen3-coder:30b": {
+ "role": "Advanced code generation (C#, XAML, SQL, K8s)",
+ "vram_gb": 18,
+ "speed": "needs ReBAR",
+ },
+ "deepseek-r1:32b": {
+ "role": "Deep reasoning (direct API, not AgentZero)",
+ "vram_gb": 22,
+ "speed": "needs ReBAR",
+ },
+ "qwen3:32b": {
+ "role": "AgentZero chat brain (JSON tool-call mode)",
+ "vram_gb": 21,
+ "speed": "needs ReBAR",
+ },
+ "nomic-embed-text": {
+ "role": "Embeddings for memory/RAG (768 dims)",
+ "vram_gb": 0.3,
+ "speed": "N/A",
+ },
+ }
+
+
+ def _get_loaded_models(api_base: str) -> list:
+ """Query Ollama for currently loaded models."""
+ try:
+ result = subprocess.run(
+ [
+ "curl", "-s", "--max-time", "5",
+ f"{api_base}/api/ps",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ data = json.loads(result.stdout)
+ models = data.get("models", [])
+ return [
+ {
+ "name": m.get("name", "unknown"),
+ "size": _format_bytes(m.get("size", 0)),
+ }
+ for m in models
+ ]
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
+ pass
+ return []
+
+
+ def _load_model(model: str, keep_alive: str, api_base: str) -> str:
+ """Load a model into VRAM by sending a minimal generate request."""
+ payload = json.dumps({
+ "model": model,
+ "prompt": "",
+ "keep_alive": keep_alive,
+ "stream": False,
+ })
+
+ try:
+ result = subprocess.run(
+ [
+ "curl", "-s", "--max-time", "120",
+ "-X", "POST",
+ f"{api_base}/api/generate",
+ "-H", "Content-Type: application/json",
+ "-d", payload,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=130,
+ )
+
+ if result.returncode == 0:
+ try:
+ response = json.loads(result.stdout)
+ if "error" in response:
+ return f"Error: {response['error']}"
+ return "Loaded successfully"
+ except json.JSONDecodeError:
+ return "Loaded (response not JSON)"
+ else:
+ return f"Failed (curl exit code {result.returncode}): {result.stderr.strip()}"
+
+ except subprocess.TimeoutExpired:
+ return "Timeout -- model may still be loading in background"
+ except FileNotFoundError:
+ return "Error: curl not found"
+
+
+ def _get_recommendation(model: str, task: str) -> str:
+ """Provide task-specific usage recommendations."""
+ recommendations = {
+ "qwen3-coder:30b": (
+ "For C#/.NET code generation, use the chat endpoint with system prompt:\n"
+ "`\"You are a C# code assistant for FlowerCore (.NET 10, EF Core, xUnit).\"`\n"
+ "Set `num_ctx: 32768` for large files. Needs full ReBAR to load on GPU."
+ ),
+ "devstral:24b": (
+ "Mistral's agentic coding specialist. Best for multi-step code generation,\n"
+ "file editing, and autonomous coding tasks. Pairs well with qwen3-vl:8b (26GB total)."
+ ),
+ "phi4:14b": (
+ "Best for architecture review and .NET design patterns.\n"
+ "Microsoft's model has strong C# ecosystem knowledge.\n"
+ "Use for reviewing service interfaces, EF Core patterns, and SOLID compliance.\n"
+ "Can be loaded alongside qwen3-coder:30b (30GB total) for review + generation."
+ ),
+ "gemma3:27b": (
+ "Send screenshots as base64 in the `images` array.\n"
+ "Strong at both vision and text tasks. Used as the A0 browser model.\n"
+ "Better vision quality than llama3.2-vision."
+ ),
+ "qwen3-vl:8b": (
+ "Fast lightweight vision model at 73 tok/s.\n"
+ "Use for quick screenshot analysis when gemma3:27b is too heavy."
+ ),
+ "granite3.1-dense:8b": (
+ "Excels at generating valid JSON schemas, K8s manifests, and OpenAPI specs.\n"
+ "Use for CRD definitions, appsettings.json templates, and structured output."
+ ),
+ "deepseek-r1:32b": (
+ "Use via direct API call, not through AgentZero.\n"
+ "Dramatically better reasoning than the 8b/14b variants.\n"
+ "Best for complex reasoning about architecture tradeoffs and debugging."
+ ),
+ }
+ return recommendations.get(model, "")
+
+
+ def _format_bytes(size: int) -> str:
+ """Format byte count to human-readable string."""
+ for unit in ["B", "KB", "MB", "GB"]:
+ if size < 1024:
+ return f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} TB"
+
+
+ def _list_available_models() -> str:
+ """Return a formatted list of all available models."""
+ info = _get_model_info()
+ lines = [
+ "## Available Ollama Models",
+ "",
+ "| Model | VRAM | Role | Speed |",
+ "|-------|------|------|-------|",
+ ]
+ for name, meta in info.items():
+ lines.append(
+ f"| `{name}` | {meta.get('vram_gb', '?')}GB | {meta['role']} | {meta.get('speed', '-')} |"
+ )
+ lines.extend([
+ "",
+ "**Usage**: Call this tool with `model` set to one of the above names.",
+ "",
+ "**VRAM budget**: AMD R9700 32GB -- 3-4 models fit simultaneously.",
+ "Loading a model may evict the least-recently-used one if VRAM is full.",
+ ])
+ return "\n".join(lines)
+ php_laravel.py: |
+ # PHP/Laravel Project Analyzer Tool
+ # Analyzes PHP/Laravel projects under /a0/work/repos/: discovers projects, parses
+ # composer.json, lists routes/migrations/models/tests, searches code, and runs artisan.
+ # Falls back to grep/find when PHP CLI is not available in the container.
+
+ import subprocess
+ import json
+ import os
+ import re
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class PhpLaravel(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Analyze PHP and Laravel projects.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options: "find_projects", "analyze_project", "list_routes",
+ "list_migrations", "list_models", "list_tests",
+ "check_dependencies", "search_code", "artisan_commands"
+ path (str): Path to the Laravel project root (relative to /a0/work/repos/).
+ Required for all actions except find_projects.
+ pattern (str): Search pattern (required for search_code).
+ file_type (str): File extension filter for search_code. Default: "php".
+
+ Returns:
+ PHP/Laravel project analysis formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ path = self.args.get("path", "")
+ pattern = self.args.get("pattern", "")
+ file_type = self.args.get("file_type", "php")
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = [
+ "find_projects", "analyze_project", "list_routes", "list_migrations",
+ "list_models", "list_tests", "check_dependencies", "search_code",
+ "artisan_commands",
+ ]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ base_path = Path("/a0/work/repos")
+ if not base_path.exists():
+ return Response(message=f"Error: Base repository path {base_path} does not exist. Check volume mounts.", break_loop=False)
+
+ # find_projects searches everywhere; other actions need a specific project path
+ if action == "find_projects":
+ return Response(message=_find_projects(base_path), break_loop=False)
+
+ if not path:
+ return Response(message=f"Error: path is required for '{action}' action", break_loop=False)
+
+ project_path = base_path / path
+ if not project_path.exists():
+ return Response(message=f"Error: Project path does not exist: {project_path}", break_loop=False)
+
+ if action == "analyze_project":
+ return Response(message=_analyze_project(project_path), break_loop=False)
+ if action == "list_routes":
+ return Response(message=_list_routes(project_path), break_loop=False)
+ if action == "list_migrations":
+ return Response(message=_list_migrations(project_path), break_loop=False)
+ if action == "list_models":
+ return Response(message=_list_models(project_path), break_loop=False)
+ if action == "list_tests":
+ return Response(message=_list_tests(project_path), break_loop=False)
+ if action == "check_dependencies":
+ return Response(message=_check_dependencies(project_path), break_loop=False)
+ if action == "search_code":
+ if not pattern:
+ return Response(message="Error: pattern is required for search_code action", break_loop=False)
+ return Response(message=_search_code(project_path, pattern, file_type), break_loop=False)
+ if action == "artisan_commands":
+ return Response(message=_artisan_commands(project_path), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented", break_loop=False)
+
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ def _php_available() -> bool:
+ """Check if the PHP CLI is available."""
+ try:
+ result = subprocess.run(
+ ["php", "--version"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ return result.returncode == 0
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False
+
+
+ def _read_composer_json(project_path: Path) -> dict | None:
+ """Read and parse composer.json from a project path."""
+ composer_file = project_path / "composer.json"
+ if not composer_file.exists():
+ return None
+ try:
+ with open(composer_file, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError):
+ return None
+
+
+ def _is_laravel(project_path: Path, composer: dict | None = None) -> bool:
+ """Detect whether a project is a Laravel application."""
+ # Quick check: artisan file exists
+ if (project_path / "artisan").exists():
+ return True
+ # Deeper check: laravel/framework in composer.json require
+ if composer is None:
+ composer = _read_composer_json(project_path)
+ if composer:
+ require = composer.get("require", {})
+ if "laravel/framework" in require:
+ return True
+ # Also check for Laravel packages (Lumen, etc.)
+ if "laravel/lumen-framework" in require:
+ return True
+ return False
+
+
+ def _count_files(directory: Path, glob_pattern: str = "*.php") -> int:
+ """Count files matching a glob pattern recursively."""
+ if not directory.exists():
+ return 0
+ try:
+ result = subprocess.run(
+ ["find", str(directory), "-type", "f", "-name", glob_pattern],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ files = [f for f in result.stdout.strip().split("\n") if f]
+ return len(files)
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return 0
+
+
+ def _find_files(directory: Path, glob_pattern: str = "*.php") -> list[str]:
+ """Find files matching a glob pattern recursively, excluding vendor/node_modules."""
+ if not directory.exists():
+ return []
+ try:
+ result = subprocess.run(
+ [
+ "find", str(directory),
+ "-type", "f", "-name", glob_pattern,
+ "-not", "-path", "*/vendor/*",
+ "-not", "-path", "*/node_modules/*",
+ "-not", "-path", "*/.git/*",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ files = [f for f in result.stdout.strip().split("\n") if f]
+ return sorted(files)
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+
+
+ # ---------------------------------------------------------------------------
+ # Action: find_projects
+ # ---------------------------------------------------------------------------
+
+ def _find_projects(base_path: Path) -> str:
+ """Find all PHP/Laravel projects under base_path by locating composer.json files."""
+ try:
+ result = subprocess.run(
+ [
+ "find", str(base_path),
+ "-type", "f", "-name", "composer.json",
+ "-not", "-path", "*/vendor/*",
+ "-not", "-path", "*/node_modules/*",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ composer_files = [f for f in result.stdout.strip().split("\n") if f]
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return "Error: find command failed or timed out"
+
+ if not composer_files:
+ return "## PHP Projects\n\nNo composer.json files found under `/a0/work/repos/`."
+
+ projects = []
+ for composer_file in sorted(composer_files):
+ project_dir = Path(composer_file).parent
+ composer = _read_composer_json(project_dir)
+ if not composer:
+ continue
+
+ name = composer.get("name", project_dir.name)
+ php_version = composer.get("require", {}).get("php", "not specified")
+ is_laravel = _is_laravel(project_dir, composer)
+
+ framework = "Laravel" if is_laravel else "PHP"
+ # Try to detect Laravel version
+ if is_laravel:
+ laravel_ver = composer.get("require", {}).get("laravel/framework", "")
+ if laravel_ver:
+ framework = f"Laravel {laravel_ver}"
+
+ relative_path = str(project_dir).replace(str(base_path) + "/", "")
+
+ projects.append({
+ "path": relative_path,
+ "name": name,
+ "framework": framework,
+ "php_version": php_version,
+ })
+
+ lines = [
+ f"## PHP Projects ({len(projects)})",
+ "",
+ f"**Search path**: `{base_path}`",
+ "",
+ "| Path | Name | Framework | PHP |",
+ "|------|------|-----------|-----|",
+ ]
+
+ for proj in projects:
+ lines.append(
+ f"| `{proj['path']}` | {proj['name']} | {proj['framework']} | {proj['php_version']} |"
+ )
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: analyze_project
+ # ---------------------------------------------------------------------------
+
+ def _analyze_project(project_path: Path) -> str:
+ """Full analysis of a Laravel/PHP project."""
+ composer = _read_composer_json(project_path)
+ if not composer:
+ return f"Error: No composer.json found at {project_path}"
+
+ name = composer.get("name", project_path.name)
+ description = composer.get("description", "No description")
+ php_version = composer.get("require", {}).get("php", "not specified")
+ is_laravel = _is_laravel(project_path, composer)
+ framework = "Laravel" if is_laravel else "PHP"
+
+ # Count dependencies
+ require_count = len(composer.get("require", {}))
+ require_dev_count = len(composer.get("require-dev", {}))
+
+ # Autoload namespaces
+ autoload = composer.get("autoload", {})
+ psr4 = autoload.get("psr-4", {})
+
+ # Count various directories
+ route_count = _count_files(project_path / "routes", "*.php")
+ migration_count = _count_files(project_path / "database" / "migrations", "*.php")
+ model_count = _count_files(project_path / "app" / "Models", "*.php")
+ controller_count = _count_files(project_path / "app" / "Http" / "Controllers", "*.php")
+ test_count = _count_files(project_path / "tests", "*.php")
+ middleware_count = _count_files(project_path / "app" / "Http" / "Middleware", "*.php")
+ command_count = _count_files(project_path / "app" / "Console" / "Commands", "*.php")
+ request_count = _count_files(project_path / "app" / "Http" / "Requests", "*.php")
+ service_count = _count_files(project_path / "app" / "Services", "*.php")
+ event_count = _count_files(project_path / "app" / "Events", "*.php")
+ listener_count = _count_files(project_path / "app" / "Listeners", "*.php")
+ job_count = _count_files(project_path / "app" / "Jobs", "*.php")
+ mail_count = _count_files(project_path / "app" / "Mail", "*.php")
+ view_count = _count_files(project_path / "resources" / "views", "*.blade.php")
+ config_count = _count_files(project_path / "config", "*.php")
+
+ # Check for .env.example
+ has_env_example = (project_path / ".env.example").exists()
+ has_env = (project_path / ".env").exists()
+
+ # Check for common config files
+ has_phpunit = (project_path / "phpunit.xml").exists() or (project_path / "phpunit.xml.dist").exists()
+ has_docker = (project_path / "Dockerfile").exists() or (project_path / "docker-compose.yml").exists()
+
+ lines = [
+ f"## PHP Project Analysis",
+ "",
+ f"**Path**: `{project_path}`",
+ f"**Name**: {name}",
+ f"**Description**: {description}",
+ f"**Framework**: {framework}",
+ f"**PHP Version**: {php_version}",
+ "",
+ "### Dependencies",
+ "",
+ f"- **Production packages**: {require_count}",
+ f"- **Dev packages**: {require_dev_count}",
+ "",
+ ]
+
+ if psr4:
+ lines.append("### Autoload Namespaces (PSR-4)")
+ lines.append("")
+ for namespace, path in psr4.items():
+ lines.append(f"- `{namespace}` -> `{path}`")
+ lines.append("")
+
+ lines.extend([
+ "### Project Structure",
+ "",
+ "| Component | Count |",
+ "|-----------|-------|",
+ f"| Routes files | {route_count} |",
+ f"| Migrations | {migration_count} |",
+ f"| Models | {model_count} |",
+ f"| Controllers | {controller_count} |",
+ f"| Middleware | {middleware_count} |",
+ f"| Artisan Commands | {command_count} |",
+ f"| Form Requests | {request_count} |",
+ f"| Services | {service_count} |",
+ f"| Events | {event_count} |",
+ f"| Listeners | {listener_count} |",
+ f"| Jobs | {job_count} |",
+ f"| Mail | {mail_count} |",
+ f"| Blade Views | {view_count} |",
+ f"| Config files | {config_count} |",
+ f"| Tests | {test_count} |",
+ "",
+ "### Configuration",
+ "",
+ f"- **.env.example**: {'Yes' if has_env_example else 'No'}",
+ f"- **.env**: {'Yes' if has_env else 'No'}",
+ f"- **PHPUnit config**: {'Yes' if has_phpunit else 'No'}",
+ f"- **Docker**: {'Yes' if has_docker else 'No'}",
+ ])
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: list_routes
+ # ---------------------------------------------------------------------------
+
+ def _list_routes(project_path: Path) -> str:
+ """Parse Laravel routes. Tries artisan first, falls back to grep."""
+ lines = [
+ f"## Laravel Routes",
+ "",
+ f"**Project**: `{project_path}`",
+ "",
+ ]
+
+ # Try artisan route:list if PHP is available
+ if _php_available() and (project_path / "artisan").exists():
+ try:
+ result = subprocess.run(
+ ["php", "artisan", "route:list", "--json"],
+ cwd=str(project_path),
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ try:
+ routes = json.loads(result.stdout)
+ lines.append(f"**Source**: `php artisan route:list` ({len(routes)} routes)")
+ lines.append("")
+ lines.append("| Method | URI | Name | Controller | Middleware |")
+ lines.append("|--------|-----|------|------------|------------|")
+
+ for route in routes[:100]:
+ method = route.get("method", "ANY")
+ uri = route.get("uri", "")
+ name = route.get("name", "")
+ action = route.get("action", "Closure")
+ middleware = route.get("middleware", "")
+ # Shorten controller path for readability
+ if isinstance(action, str) and "\\" in action:
+ action = action.split("\\")[-1]
+ if isinstance(middleware, list):
+ middleware = ", ".join(middleware)
+ lines.append(f"| {method} | `{uri}` | {name} | {action} | {middleware} |")
+
+ if len(routes) > 100:
+ lines.append(f"| ... | {len(routes) - 100} more routes | | | |")
+
+ return "\n".join(lines)
+ except json.JSONDecodeError:
+ pass # Fall through to grep method
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ pass # Fall through to grep method
+
+ # Fallback: grep for Route:: patterns in routes/ directory
+ routes_dir = project_path / "routes"
+ if not routes_dir.exists():
+ lines.append("No `routes/` directory found.")
+ return "\n".join(lines)
+
+ lines.append("**Source**: Static analysis (PHP CLI not available)")
+ lines.append("")
+
+ route_files = _find_files(routes_dir, "*.php")
+ if not route_files:
+ lines.append("No PHP route files found.")
+ return "\n".join(lines)
+
+ total_routes = 0
+
+ for route_file in route_files:
+ file_name = Path(route_file).name
+ try:
+ with open(route_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ continue
+
+ # Match Route::get/post/put/patch/delete/any/match/resource/apiResource
+ route_pattern = re.compile(
+ r"Route::(get|post|put|patch|delete|any|match|resource|apiResource|middleware|group|prefix)\s*\(",
+ re.IGNORECASE,
+ )
+
+ # More specific pattern for individual routes with URI
+ specific_pattern = re.compile(
+ r"Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]+)['\"]",
+ re.IGNORECASE,
+ )
+
+ matches = specific_pattern.findall(content)
+ resource_pattern = re.compile(
+ r"Route::(resource|apiResource)\s*\(\s*['\"]([^'\"]+)['\"]",
+ re.IGNORECASE,
+ )
+ resource_matches = resource_pattern.findall(content)
+
+ if matches or resource_matches:
+ lines.append(f"### `{file_name}`")
+ lines.append("")
+ lines.append("| Method | URI |")
+ lines.append("|--------|-----|")
+
+ for method, uri in matches:
+ lines.append(f"| {method.upper()} | `{uri}` |")
+ total_routes += 1
+
+ for res_type, uri in resource_matches:
+ lines.append(f"| {res_type.upper()} | `{uri}` (7 CRUD routes) |")
+ total_routes += 7
+
+ lines.append("")
+
+ lines.insert(3, f"**Total routes found**: ~{total_routes}")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: list_migrations
+ # ---------------------------------------------------------------------------
+
+ def _list_migrations(project_path: Path) -> str:
+ """List database migrations sorted by name."""
+ migrations_dir = project_path / "database" / "migrations"
+ if not migrations_dir.exists():
+ return f"## Laravel Migrations\n\n**Project**: `{project_path}`\n\nNo `database/migrations/` directory found."
+
+ migration_files = _find_files(migrations_dir, "*.php")
+
+ lines = [
+ f"## Laravel Migrations ({len(migration_files)})",
+ "",
+ f"**Project**: `{project_path}`",
+ "",
+ ]
+
+ if not migration_files:
+ lines.append("No migration files found.")
+ return "\n".join(lines)
+
+ lines.append("| Date | Migration | Table |")
+ lines.append("|------|-----------|-------|")
+
+ for mig_file in migration_files:
+ filename = Path(mig_file).stem
+ # Extract date from filename: 2024_01_15_000000_create_users_table
+ date_match = re.match(r"(\d{4}_\d{2}_\d{2}_\d{6})_(.*)", filename)
+ if date_match:
+ date_str = date_match.group(1).replace("_", "-", 2).replace("_", " ", 1)
+ migration_name = date_match.group(2)
+ else:
+ date_str = ""
+ migration_name = filename
+
+ # Try to extract table name from migration name
+ table_name = ""
+ table_match = re.search(r"(?:create|modify|update|alter|add_\w+_to|drop)_(\w+?)_table", migration_name)
+ if table_match:
+ table_name = table_match.group(1)
+ else:
+ # Try simpler pattern
+ table_match = re.search(r"create_(\w+)", migration_name)
+ if table_match:
+ table_name = table_match.group(1)
+
+ lines.append(f"| {date_str} | `{migration_name}` | {table_name} |")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: list_models
+ # ---------------------------------------------------------------------------
+
+ def _list_models(project_path: Path) -> str:
+ """List Eloquent models with their relationships."""
+ models_dir = project_path / "app" / "Models"
+ if not models_dir.exists():
+ return f"## Eloquent Models\n\n**Project**: `{project_path}`\n\nNo `app/Models/` directory found."
+
+ model_files = _find_files(models_dir, "*.php")
+
+ lines = [
+ f"## Eloquent Models ({len(model_files)})",
+ "",
+ f"**Project**: `{project_path}`",
+ "",
+ ]
+
+ if not model_files:
+ lines.append("No model files found.")
+ return "\n".join(lines)
+
+ relationship_patterns = {
+ "hasMany": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->hasMany\(", re.DOTALL),
+ "belongsTo": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->belongsTo\(", re.DOTALL),
+ "hasOne": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->hasOne\(", re.DOTALL),
+ "belongsToMany": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->belongsToMany\(", re.DOTALL),
+ "morphMany": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->morphMany\(", re.DOTALL),
+ "morphOne": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->morphOne\(", re.DOTALL),
+ "morphTo": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->morphTo\(", re.DOTALL),
+ "morphToMany": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->morphToMany\(", re.DOTALL),
+ "hasManyThrough": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->hasManyThrough\(", re.DOTALL),
+ "hasOneThrough": re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\$this->hasOneThrough\(", re.DOTALL),
+ }
+
+ # Simpler fallback patterns (just look for the method call, less strict matching)
+ simple_patterns = {
+ "hasMany": re.compile(r"\$this->hasMany\("),
+ "belongsTo": re.compile(r"\$this->belongsTo\("),
+ "hasOne": re.compile(r"\$this->hasOne\("),
+ "belongsToMany": re.compile(r"\$this->belongsToMany\("),
+ "morphMany": re.compile(r"\$this->morphMany\("),
+ "morphOne": re.compile(r"\$this->morphOne\("),
+ "morphTo": re.compile(r"\$this->morphTo\("),
+ "morphToMany": re.compile(r"\$this->morphToMany\("),
+ "hasManyThrough": re.compile(r"\$this->hasManyThrough\("),
+ "hasOneThrough": re.compile(r"\$this->hasOneThrough\("),
+ }
+
+ for model_file in model_files:
+ model_name = Path(model_file).stem
+
+ try:
+ with open(model_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ lines.append(f"- **{model_name}** (could not read file)")
+ continue
+
+ # Detect traits
+ traits = []
+ trait_match = re.findall(r"use\s+([\w\\]+(?:,\s*[\w\\]+)*)\s*;", content)
+ for match in trait_match:
+ for trait in match.split(","):
+ trait = trait.strip().split("\\")[-1]
+ if trait not in ("HasFactory", "Model") and not trait.startswith("use"):
+ traits.append(trait)
+
+ # Detect fillable
+ fillable_match = re.search(r"\$fillable\s*=\s*\[(.*?)\]", content, re.DOTALL)
+ fillable_count = 0
+ if fillable_match:
+ fillable_items = re.findall(r"['\"](\w+)['\"]", fillable_match.group(1))
+ fillable_count = len(fillable_items)
+
+ # Detect relationships using simple patterns first for counting
+ relationships = []
+ for rel_type, pattern in simple_patterns.items():
+ count = len(pattern.findall(content))
+ if count > 0:
+ # Try to find method names using the detailed pattern
+ detailed = relationship_patterns[rel_type]
+ method_matches = detailed.findall(content)
+ if method_matches:
+ for method_name in method_matches:
+ relationships.append(f"{rel_type}:{method_name}")
+ else:
+ # Couldn't parse method names, just report count
+ for i in range(count):
+ relationships.append(rel_type)
+
+ lines.append(f"### {model_name}")
+ lines.append("")
+
+ details = []
+ if fillable_count:
+ details.append(f"**Fillable**: {fillable_count} fields")
+ if traits:
+ details.append(f"**Traits**: {', '.join(traits)}")
+ if details:
+ lines.append("- " + " | ".join(details))
+
+ if relationships:
+ lines.append("- **Relationships**:")
+ for rel in relationships:
+ if ":" in rel:
+ rel_type, method = rel.split(":", 1)
+ lines.append(f" - `{method}()` -> {rel_type}")
+ else:
+ lines.append(f" - {rel}")
+ else:
+ lines.append("- No relationships detected")
+
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: list_tests
+ # ---------------------------------------------------------------------------
+
+ def _list_tests(project_path: Path) -> str:
+ """List test files and count test methods, separating Unit vs Feature."""
+ tests_dir = project_path / "tests"
+ if not tests_dir.exists():
+ return f"## PHP Tests\n\n**Project**: `{project_path}`\n\nNo `tests/` directory found."
+
+ test_files = _find_files(tests_dir, "*.php")
+
+ lines = [
+ f"## PHP Tests",
+ "",
+ f"**Project**: `{project_path}`",
+ "",
+ ]
+
+ if not test_files:
+ lines.append("No test files found.")
+ return "\n".join(lines)
+
+ unit_tests = []
+ feature_tests = []
+ other_tests = []
+ total_methods = 0
+
+ for test_file in test_files:
+ relative = str(test_file).replace(str(tests_dir) + "/", "")
+ filename = Path(test_file).stem
+
+ # Skip base TestCase files
+ if filename in ("TestCase", "CreatesApplication", "DuskTestCase"):
+ continue
+
+ try:
+ with open(test_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ continue
+
+ # Count test methods: function test* or @test annotation
+ method_count = len(re.findall(r"(?:public\s+)?function\s+test\w+\s*\(", content))
+ annotation_count = len(re.findall(r"@test\b", content))
+ # For @test annotated methods, count distinct methods following the annotation
+ test_method_count = method_count + annotation_count
+ total_methods += test_method_count
+
+ entry = {
+ "file": relative,
+ "name": filename,
+ "methods": test_method_count,
+ }
+
+ if "/Unit/" in test_file or "\\Unit\\" in test_file:
+ unit_tests.append(entry)
+ elif "/Feature/" in test_file or "\\Feature\\" in test_file:
+ feature_tests.append(entry)
+ else:
+ other_tests.append(entry)
+
+ lines.append(f"**Total test files**: {len(unit_tests) + len(feature_tests) + len(other_tests)}")
+ lines.append(f"**Total test methods**: {total_methods}")
+ lines.append("")
+
+ if unit_tests:
+ unit_method_total = sum(t["methods"] for t in unit_tests)
+ lines.append(f"### Unit Tests ({len(unit_tests)} files, {unit_method_total} methods)")
+ lines.append("")
+ lines.append("| File | Methods |")
+ lines.append("|------|---------|")
+ for t in unit_tests:
+ lines.append(f"| `{t['file']}` | {t['methods']} |")
+ lines.append("")
+
+ if feature_tests:
+ feature_method_total = sum(t["methods"] for t in feature_tests)
+ lines.append(f"### Feature Tests ({len(feature_tests)} files, {feature_method_total} methods)")
+ lines.append("")
+ lines.append("| File | Methods |")
+ lines.append("|------|---------|")
+ for t in feature_tests:
+ lines.append(f"| `{t['file']}` | {t['methods']} |")
+ lines.append("")
+
+ if other_tests:
+ other_method_total = sum(t["methods"] for t in other_tests)
+ lines.append(f"### Other Tests ({len(other_tests)} files, {other_method_total} methods)")
+ lines.append("")
+ lines.append("| File | Methods |")
+ lines.append("|------|---------|")
+ for t in other_tests:
+ lines.append(f"| `{t['file']}` | {t['methods']} |")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: check_dependencies
+ # ---------------------------------------------------------------------------
+
+ def _check_dependencies(project_path: Path) -> str:
+ """Analyze composer.json dependencies."""
+ composer = _read_composer_json(project_path)
+ if not composer:
+ return f"Error: No composer.json found at {project_path}"
+
+ lines = [
+ f"## Composer Dependencies",
+ "",
+ f"**Project**: `{composer.get('name', project_path.name)}`",
+ "",
+ ]
+
+ require = composer.get("require", {})
+ require_dev = composer.get("require-dev", {})
+
+ # Security-relevant packages to flag
+ security_packages = {
+ "laravel/sanctum", "laravel/passport", "tymon/jwt-auth",
+ "spatie/laravel-permission", "silber/bouncer",
+ "pragmarx/google2fa-laravel", "laravel/fortify",
+ "laravel/breeze", "laravel/jetstream", "laravel/socialite",
+ }
+
+ # Common Laravel ecosystem packages to highlight
+ notable_packages = {
+ "laravel/horizon": "Queue monitoring",
+ "laravel/telescope": "Debug assistant",
+ "laravel/nova": "Admin panel",
+ "laravel/cashier": "Billing (Stripe)",
+ "laravel/scout": "Full-text search",
+ "laravel/dusk": "Browser testing",
+ "spatie/laravel-backup": "Backup",
+ "spatie/laravel-medialibrary": "Media management",
+ "spatie/laravel-activitylog": "Activity logging",
+ "barryvdh/laravel-debugbar": "Debug toolbar",
+ "barryvdh/laravel-ide-helper": "IDE helper",
+ "twilio/sdk": "Twilio API",
+ "guzzlehttp/guzzle": "HTTP client",
+ "league/flysystem-aws-s3-v3": "S3 storage",
+ "predis/predis": "Redis client",
+ "maatwebsite/excel": "Excel import/export",
+ }
+
+ if require:
+ lines.append(f"### Production Dependencies ({len(require)})")
+ lines.append("")
+ lines.append("| Package | Version | Notes |")
+ lines.append("|---------|---------|-------|")
+
+ for pkg, version in sorted(require.items()):
+ notes = []
+ if pkg in security_packages:
+ notes.append("AUTH/SECURITY")
+ if pkg in notable_packages:
+ notes.append(notable_packages[pkg])
+ if pkg == "php":
+ notes.append("PHP runtime")
+
+ note_str = ", ".join(notes) if notes else ""
+ lines.append(f"| `{pkg}` | {version} | {note_str} |")
+ lines.append("")
+
+ if require_dev:
+ lines.append(f"### Dev Dependencies ({len(require_dev)})")
+ lines.append("")
+ lines.append("| Package | Version | Notes |")
+ lines.append("|---------|---------|-------|")
+
+ for pkg, version in sorted(require_dev.items()):
+ notes = []
+ if pkg in notable_packages:
+ notes.append(notable_packages[pkg])
+ if "test" in pkg.lower() or "phpunit" in pkg.lower():
+ notes.append("Testing")
+ if "lint" in pkg.lower() or "cs-fixer" in pkg.lower() or "phpstan" in pkg.lower():
+ notes.append("Code quality")
+
+ note_str = ", ".join(notes) if notes else ""
+ lines.append(f"| `{pkg}` | {version} | {note_str} |")
+ lines.append("")
+
+ # Check composer.lock for installed versions
+ lock_file = project_path / "composer.lock"
+ if lock_file.exists():
+ lines.append("**composer.lock**: Present (versions locked)")
+ else:
+ lines.append("**composer.lock**: Missing (versions not locked)")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: search_code
+ # ---------------------------------------------------------------------------
+
+ def _search_code(project_path: Path, pattern: str, file_type: str) -> str:
+ """Search PHP code using ripgrep or grep fallback."""
+ glob_pattern = f"*.{file_type}"
+
+ # Try ripgrep first
+ result_text = _rg_search(project_path, pattern, glob_pattern)
+ if result_text is None:
+ result_text = _grep_search(project_path, pattern, glob_pattern)
+
+ return result_text
+
+
+ def _rg_search(project_path: Path, pattern: str, glob_pattern: str) -> str | None:
+ """Search using ripgrep."""
+ cmd = [
+ "rg", "-n", "--heading", "-i",
+ "--glob", glob_pattern,
+ "--glob", "!vendor/**",
+ "--glob", "!node_modules/**",
+ "--glob", "!.git/**",
+ "--glob", "!storage/**",
+ "--glob", "!bootstrap/cache/**",
+ "-m", "50",
+ "-C", "1",
+ pattern,
+ str(project_path),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except FileNotFoundError:
+ return None # rg not installed
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 30 seconds. Try a more specific pattern."
+
+ if result.returncode == 1:
+ return f"## Code Search Results\n\nNo matches found for `{pattern}` in `{glob_pattern}` files."
+ if result.returncode > 1:
+ return f"Error: ripgrep returned code {result.returncode}: {result.stderr.strip()}"
+
+ output_lines = result.stdout.strip().split("\n")
+
+ lines = [
+ f"## Code Search Results",
+ "",
+ f"- **Pattern**: `{pattern}`",
+ f"- **File type**: `{glob_pattern}`",
+ f"- **Project**: `{project_path}`",
+ "",
+ "```",
+ ]
+
+ cap = min(len(output_lines), 200)
+ for line in output_lines[:cap]:
+ display_line = line.replace(str(project_path) + "/", "")
+ lines.append(display_line)
+
+ if len(output_lines) > cap:
+ lines.append(f"... ({len(output_lines) - cap} more lines)")
+
+ lines.append("```")
+ return "\n".join(lines)
+
+
+ def _grep_search(project_path: Path, pattern: str, glob_pattern: str) -> str:
+ """Fallback search using grep."""
+ cmd = [
+ "grep", "-r", "-n", "-i",
+ "--include", glob_pattern,
+ "--exclude-dir=vendor",
+ "--exclude-dir=node_modules",
+ "--exclude-dir=.git",
+ "--exclude-dir=storage",
+ "--exclude-dir=bootstrap",
+ "-C", "1",
+ pattern,
+ str(project_path),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except FileNotFoundError:
+ return "Error: Neither ripgrep (rg) nor grep found. Cannot search."
+ except subprocess.TimeoutExpired:
+ return "Error: Search timed out after 30 seconds. Try a more specific pattern."
+
+ if result.returncode == 1:
+ return f"## Code Search Results\n\nNo matches found for `{pattern}` in `{glob_pattern}` files."
+ if result.returncode > 1:
+ return f"Error: grep returned code {result.returncode}: {result.stderr.strip()}"
+
+ output_lines = result.stdout.strip().split("\n")
+
+ lines = [
+ f"## Code Search Results",
+ "",
+ f"- **Pattern**: `{pattern}`",
+ f"- **File type**: `{glob_pattern}`",
+ f"- **Project**: `{project_path}`",
+ "",
+ "```",
+ ]
+
+ cap = min(len(output_lines), 200)
+ for line in output_lines[:cap]:
+ display_line = line.replace(str(project_path) + "/", "")
+ lines.append(display_line)
+
+ if len(output_lines) > cap:
+ lines.append(f"... ({len(output_lines) - cap} more lines)")
+
+ lines.append("```")
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Action: artisan_commands
+ # ---------------------------------------------------------------------------
+
+ def _artisan_commands(project_path: Path) -> str:
+ """List available artisan commands."""
+ if not (project_path / "artisan").exists():
+ return f"## Artisan Commands\n\n**Project**: `{project_path}`\n\nNo `artisan` file found. This may not be a Laravel project."
+
+ lines = [
+ f"## Artisan Commands",
+ "",
+ f"**Project**: `{project_path}`",
+ "",
+ ]
+
+ # Try running artisan list
+ if _php_available():
+ try:
+ result = subprocess.run(
+ ["php", "artisan", "list", "--format=json"],
+ cwd=str(project_path),
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ try:
+ data = json.loads(result.stdout)
+ commands = data.get("commands", [])
+ namespaces = data.get("namespaces", [])
+
+ lines.append(f"**Source**: `php artisan list` ({len(commands)} commands)")
+ lines.append("")
+
+ # Group commands by namespace
+ if namespaces:
+ for ns in namespaces:
+ ns_id = ns.get("id", "")
+ ns_commands = ns.get("commands", [])
+ if ns_id:
+ lines.append(f"### {ns_id} ({len(ns_commands)})")
+ else:
+ lines.append(f"### Global ({len(ns_commands)})")
+ lines.append("")
+ for cmd_name in ns_commands[:20]:
+ # Find description from commands list
+ desc = ""
+ for cmd in commands:
+ if cmd.get("name") == cmd_name:
+ desc = cmd.get("description", "")
+ break
+ lines.append(f"- `{cmd_name}` — {desc}")
+ if len(ns_commands) > 20:
+ lines.append(f"- ... and {len(ns_commands) - 20} more")
+ lines.append("")
+ else:
+ # No namespace grouping, list flat
+ for cmd in commands[:50]:
+ name = cmd.get("name", "")
+ desc = cmd.get("description", "")
+ lines.append(f"- `{name}` — {desc}")
+ if len(commands) > 50:
+ lines.append(f"- ... and {len(commands) - 50} more")
+
+ return "\n".join(lines)
+ except json.JSONDecodeError:
+ pass # Fall through to plain text
+
+ # Try plain text format
+ result = subprocess.run(
+ ["php", "artisan", "list"],
+ cwd=str(project_path),
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ lines.append("**Source**: `php artisan list` (plain text)")
+ lines.append("")
+ lines.append("```")
+ # Cap output
+ output_lines = result.stdout.strip().split("\n")
+ for line in output_lines[:80]:
+ lines.append(line)
+ if len(output_lines) > 80:
+ lines.append(f"... ({len(output_lines) - 80} more lines)")
+ lines.append("```")
+ return "\n".join(lines)
+
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ pass # Fall through to static list
+
+ # Fallback: list common artisan commands and detect custom ones
+ lines.append("**Source**: Static list (PHP CLI not available)")
+ lines.append("")
+ lines.append("### Common Laravel Artisan Commands")
+ lines.append("")
+
+ common_commands = [
+ ("serve", "Start the development server"),
+ ("migrate", "Run database migrations"),
+ ("migrate:rollback", "Rollback the last migration"),
+ ("migrate:fresh", "Drop all tables and re-run migrations"),
+ ("migrate:status", "Show migration status"),
+ ("db:seed", "Seed the database"),
+ ("make:model", "Create a new Eloquent model"),
+ ("make:controller", "Create a new controller"),
+ ("make:migration", "Create a new migration file"),
+ ("make:middleware", "Create a new middleware"),
+ ("make:request", "Create a new form request"),
+ ("make:command", "Create a new artisan command"),
+ ("make:test", "Create a new test class"),
+ ("route:list", "List all registered routes"),
+ ("route:cache", "Create a route cache file"),
+ ("config:cache", "Create a configuration cache file"),
+ ("cache:clear", "Flush the application cache"),
+ ("queue:work", "Start processing jobs on the queue"),
+ ("schedule:run", "Run the scheduled commands"),
+ ("test", "Run the application tests"),
+ ("tinker", "Interact with your application"),
+ ("optimize", "Cache configuration, routes, and views"),
+ ]
+
+ for cmd, desc in common_commands:
+ lines.append(f"- `php artisan {cmd}` — {desc}")
+
+ # Detect custom commands
+ commands_dir = project_path / "app" / "Console" / "Commands"
+ if commands_dir.exists():
+ custom_files = _find_files(commands_dir, "*.php")
+ if custom_files:
+ lines.append("")
+ lines.append(f"### Custom Commands Detected ({len(custom_files)} files)")
+ lines.append("")
+ for cmd_file in custom_files:
+ cmd_name = Path(cmd_file).stem
+ # Try to extract $signature from file
+ try:
+ with open(cmd_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ sig_match = re.search(r"\$signature\s*=\s*['\"]([^'\"]+)['\"]", content)
+ desc_match = re.search(r"\$description\s*=\s*['\"]([^'\"]+)['\"]", content)
+ signature = sig_match.group(1) if sig_match else cmd_name
+ description = desc_match.group(1) if desc_match else ""
+ lines.append(f"- `{signature}` — {description}")
+ except (IOError, UnicodeDecodeError):
+ lines.append(f"- `{cmd_name}` (could not parse)")
+
+ return "\n".join(lines)
+
+
+ # ---------------------------------------------------------------------------
+ # Usage
+ # ---------------------------------------------------------------------------
+
+ def _show_usage() -> str:
+ """Show tool usage help."""
+ return """## PHP/Laravel Analyzer Usage
+
+ Analyzes PHP and Laravel projects under `/a0/work/repos/`.
+ Falls back to static analysis when PHP CLI is not available.
+
+ ### Available Actions
+
+ | Action | Description | Required Args |
+ |--------|-------------|---------------|
+ | `find_projects` | Find all PHP/Laravel projects | None |
+ | `analyze_project` | Full project analysis | `path` |
+ | `list_routes` | Parse Laravel routes | `path` |
+ | `list_migrations` | List database migrations | `path` |
+ | `list_models` | List Eloquent models with relationships | `path` |
+ | `list_tests` | List test files and count methods | `path` |
+ | `check_dependencies` | Analyze composer.json | `path` |
+ | `search_code` | Search PHP code | `path`, `pattern` |
+ | `artisan_commands` | List artisan commands | `path` |
+
+ ### Examples
+
+ ```python
+ # Find all PHP projects
+ {"action": "find_projects"}
+
+ # Full project analysis
+ {"action": "analyze_project", "path": "Projects/twilio-phone-system"}
+
+ # List routes
+ {"action": "list_routes", "path": "Projects/twilio-phone-system"}
+
+ # List migrations
+ {"action": "list_migrations", "path": "Projects/twilio-phone-system"}
+
+ # List models with relationships
+ {"action": "list_models", "path": "Projects/twilio-phone-system"}
+
+ # List tests (Unit vs Feature)
+ {"action": "list_tests", "path": "Projects/twilio-phone-system"}
+
+ # Check dependencies
+ {"action": "check_dependencies", "path": "Projects/twilio-phone-system"}
+
+ # Search for Twilio usage in PHP files
+ {"action": "search_code", "path": "Projects/twilio-phone-system", "pattern": "TwilioClient"}
+
+ # Search in Blade templates
+ {"action": "search_code", "path": "Projects/twilio-phone-system", "pattern": "@extends", "file_type": "blade.php"}
+
+ # List artisan commands
+ {"action": "artisan_commands", "path": "Projects/twilio-phone-system"}
+ ```
+
+ ### Path Notes
+
+ All paths are relative to `/a0/work/repos/`.
+ The Twilio IVR reference project is at `Projects/twilio-phone-system`.
+ FlowerCore PHP Manager is at `FlowerCore/FlowerCore.PHP/`.
+
+ ### PHP CLI
+
+ If PHP is installed in the container, artisan-based commands will use it for accurate results.
+ Otherwise, the tool falls back to static file analysis (grep/find) which covers most use cases.
+ """
+ print_web.py: |
+ # FlowerCore Print.Web Integration Tool
+ # Interfaces with the thermal print service at https://print.iamworkin.lan
+ # Supports printing receipts, barcodes, QR codes, labels, images, recipes,
+ # plus status queries for paper, queue, printer, and product lookup.
+ # All operations return markdown-formatted responses with inline image previews.
+ #
+ # PRINT-MEGA Sprint additions (2026-04-04):
+ # - ai_summary: Generate AI summary of text/URL, optionally print
+ # - recipe_print: Enhanced recipe with Selenium Grid fallback for JS sites
+ # - product_search: Barcode/name lookup via Ollama + SQLite cache
+
+ import json
+ import base64
+ import os
+ from urllib.parse import quote
+
+ from python.helpers.tool import Tool, Response
+
+ PRINT_WEB_URL = os.environ.get("PRINT_WEB_URL", "http://10.0.57.16:5200")
+ PRINT_WEB_API_KEY = os.environ.get("PRINT_WEB_API_KEY", "")
+
+
+ class PrintWeb(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ FlowerCore Print.Web thermal printer interface.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options:
+ Print: "receipt", "barcode", "qr", "label", "image", "test", "url", "recipe", "recipe_print"
+ AI: "ai_summary", "product_search"
+ Status: "status", "paper", "queue", "hardware", "waste"
+ Lookup: "product"
+ Control: "drawer", "clear_queue"
+
+ # Receipt args
+ header (str): Receipt header text
+ lines (list): Receipt lines as [{left, right, bold?, separator?}]
+ footer (str): Receipt footer text
+
+ # Barcode args
+ data (str): Barcode/QR data to encode
+ symbology (str): Barcode type: Code128, UpcA, Ean13, QR, etc. Default: Code128
+ title (str): Label title text
+ subtitle (str): Label subtitle text
+ copies (int): Number of copies. Default: 1
+
+ # QR args
+ label (str): Text label below QR code
+ module_size (int): QR module size. Default: 6
+
+ # Image args
+ image_base64 (str): Base64-encoded image data (PNG/JPG)
+ image_path (str): Local file path to image (alternative to base64)
+
+ # URL/Recipe args
+ url (str): URL to print or recipe to scrape
+
+ # Product lookup / search
+ barcode (str): UPC/EAN barcode to look up
+ query (str): Product name/description to search (for product_search)
+
+ # AI summary
+ text (str): Text to summarize (for ai_summary)
+ print_result (bool): Also print the summary. Default: false
+ model (str): Ollama model override (default: uses service config)
+
+ # Queue management
+ source (str): Source name for clear_queue
+
+ # General
+ dry_run (bool): Log job without printing. Default: false
+
+ Returns:
+ Markdown-formatted results with inline preview images where available.
+ """
+ action = kwargs.get("action", "status")
+ dry_run = kwargs.get("dry_run", False)
+ dry_param = "?dryRun=true" if dry_run else ""
+
+ try:
+ if action == "receipt":
+ return await self._print_receipt(kwargs, dry_param)
+ elif action == "barcode":
+ return await self._print_barcode(kwargs, dry_param)
+ elif action == "qr":
+ return await self._print_qr(kwargs, dry_param)
+ elif action == "label":
+ return await self._print_label(kwargs, dry_param)
+ elif action == "image":
+ return await self._print_image(kwargs)
+ elif action == "test":
+ return await self._api_post("/api/print/test", {})
+ elif action == "url":
+ return await self._print_url(kwargs, dry_param)
+ elif action == "recipe":
+ return await self._print_recipe(kwargs)
+ elif action == "status":
+ return await self._get_status()
+ elif action == "paper":
+ return await self._get_paper()
+ elif action == "queue":
+ return await self._get_queue()
+ elif action == "hardware":
+ return await self._get_hardware()
+ elif action == "waste":
+ return await self._get_waste(kwargs)
+ elif action == "product":
+ return await self._lookup_product(kwargs)
+ elif action == "drawer":
+ return await self._open_drawer()
+ elif action == "clear_queue":
+ return await self._clear_queue(kwargs)
+ elif action == "ai_summary":
+ return await self._ai_summary(kwargs)
+ elif action == "recipe_print":
+ return await self._recipe_print_enhanced(kwargs)
+ elif action == "product_search":
+ return await self._product_search(kwargs)
+ else:
+ return Response(
+ message=f"Unknown action: {action}. Available: receipt, barcode, qr, label, image, test, url, recipe, recipe_print, ai_summary, product_search, status, paper, queue, hardware, waste, product, drawer, clear_queue",
+ break_loop=False
+ )
+ except Exception as e:
+ return Response(message=f"**Print.Web error:** {e}", break_loop=False)
+
+ async def _print_receipt(self, kwargs, dry_param):
+ payload = {
+ "header": kwargs.get("header", ""),
+ "lines": kwargs.get("lines", []),
+ "footer": kwargs.get("footer", ""),
+ "autoCut": kwargs.get("auto_cut", True),
+ "openDrawer": kwargs.get("open_drawer", False),
+ }
+ result = await self._api_post(f"/api/print/receipt{dry_param}", payload)
+ return self._format_print_result(result, "Receipt")
+
+ async def _print_barcode(self, kwargs, dry_param):
+ payload = {
+ "data": kwargs.get("data", ""),
+ "symbology": kwargs.get("symbology", "Code128"),
+ "humanReadableText": kwargs.get("human_readable", True),
+ "title": kwargs.get("title", ""),
+ "subtitle": kwargs.get("subtitle", ""),
+ "copies": kwargs.get("copies", 1),
+ }
+ result = await self._api_post(f"/api/print/barcode{dry_param}", payload)
+ return self._format_print_result(result, "Barcode")
+
+ async def _print_qr(self, kwargs, dry_param):
+ payload = {
+ "data": kwargs.get("data", ""),
+ "label": kwargs.get("label", ""),
+ "moduleSize": kwargs.get("module_size", 6),
+ }
+ result = await self._api_post(f"/api/print/qr{dry_param}", payload)
+ return self._format_print_result(result, "QR Code")
+
+ async def _print_label(self, kwargs, dry_param):
+ payload = {
+ "title": kwargs.get("title", ""),
+ "subtitle": kwargs.get("subtitle", ""),
+ "data": kwargs.get("data", ""),
+ "copies": kwargs.get("copies", 1),
+ }
+ result = await self._api_post(f"/api/print/label{dry_param}", payload)
+ return self._format_print_result(result, "Label")
+
+ async def _print_image(self, kwargs):
+ image_b64 = kwargs.get("image_base64", "")
+ image_path = kwargs.get("image_path", "")
+ if image_path and not image_b64:
+ with open(image_path, "rb") as f:
+ image_b64 = base64.b64encode(f.read()).decode()
+ payload = {
+ "imageBase64": image_b64,
+ "maxWidth": kwargs.get("max_width", 384),
+ "label": kwargs.get("label", ""),
+ }
+ result = await self._api_post("/api/print/image", payload)
+ return self._format_print_result(result, "Image")
+
+ async def _print_url(self, kwargs, dry_param):
+ payload = {
+ "url": kwargs.get("url", ""),
+ "title": kwargs.get("title", ""),
+ }
+ result = await self._api_post(f"/api/print/url{dry_param}", payload)
+ return self._format_print_result(result, "URL")
+
+ async def _print_recipe(self, kwargs):
+ payload = {"url": kwargs.get("url", "")}
+ result = await self._api_post("/api/print/recipe", payload)
+ return self._format_print_result(result, "Recipe")
+
+ async def _get_status(self):
+ data = await self._api_get("/api/print/status")
+ if isinstance(data, dict):
+ return Response(
+ message=f"## Printer Status\n"
+ f"- **Connected:** {'Yes' if data.get('connected') else 'No'}\n"
+ f"- **Queue depth:** {data.get('queueDepth', 0)}\n"
+ f"- **Completed jobs:** {data.get('completedCount', 0)}\n"
+ f"- **Paper remaining:** {data.get('paperRemainingPercent', '?')}%\n",
+ break_loop=False
+ )
+ return Response(message=f"Status: {data}", break_loop=False)
+
+ async def _get_paper(self):
+ data = await self._api_get("/api/paper/status")
+ if isinstance(data, dict):
+ pct = data.get("remainingPercent", 0)
+ feet = data.get("remainingFeet", 0)
+ jobs = data.get("estimatedJobsRemaining", 0)
+ low = " **LOW PAPER!**" if data.get("isLow") else ""
+ return Response(
+ message=f"## Paper Roll Status{low}\n"
+ f"- **Remaining:** {pct:.0f}% ({feet:.1f} ft)\n"
+ f"- **Estimated jobs left:** {jobs}\n"
+ f"- **Total jobs on this roll:** {data.get('jobCount', 0)}\n",
+ break_loop=False
+ )
+ return Response(message=f"Paper: {data}", break_loop=False)
+
+ async def _get_queue(self):
+ data = await self._api_get("/api/print/queue?limit=10")
+ if isinstance(data, list):
+ if not data:
+ return Response(message="## Print Queue\nNo pending jobs.", break_loop=False)
+ lines = ["## Print Queue", f"**{len(data)} jobs:**", ""]
+ for job in data[:10]:
+ status = job.get("status", "?")
+ jtype = job.get("jobType", "?")
+ source = job.get("sourceApp", "?")
+ lines.append(f"- [{status}] {jtype} from {source}")
+ return Response(message="\n".join(lines), break_loop=False)
+ return Response(message=f"Queue: {data}", break_loop=False)
+
+ async def _get_hardware(self):
+ data = await self._api_get("/api/printer/hardware-status")
+ if isinstance(data, dict):
+ return Response(
+ message=f"## Hardware Status\n"
+ f"- **Status supported:** {data.get('statusSupported', False)}\n"
+ f"- **Paper:** {'OK' if data.get('hasPaper') else 'OUT'}\n"
+ f"- **Online:** {'Yes' if data.get('isOnline') else 'No'}\n"
+ f"- **Error:** {data.get('errorDescription', 'None')}\n",
+ break_loop=False
+ )
+ return Response(message=f"Hardware: {data}", break_loop=False)
+
+ async def _get_waste(self, kwargs):
+ days = kwargs.get("days", 7)
+ data = await self._api_get(f"/api/paper/waste-summary?days={days}")
+ if isinstance(data, dict):
+ return Response(
+ message=f"## Paper Waste ({days} days)\n"
+ f"- **Total waste:** {data.get('totalWasteMm', 0):.0f}mm\n"
+ f"- **Waste %:** {data.get('wastePercent', 0):.1f}%\n",
+ break_loop=False
+ )
+ return Response(message=f"Waste: {data}", break_loop=False)
+
+ async def _lookup_product(self, kwargs):
+ barcode = kwargs.get("barcode", "")
+ data = await self._api_get(f"/api/product/lookup/{quote(barcode)}")
+ if isinstance(data, dict) and data.get("name"):
+ img = ""
+ if data.get("imageUrl"):
+ img = f"\n\n"
+ return Response(
+ message=f"## Product: {data.get('name', '?')}{img}\n"
+ f"- **Brand:** {data.get('brand', '?')}\n"
+ f"- **Barcode:** {data.get('barcode', barcode)}\n"
+ f"- **Category:** {data.get('category', '?')}\n"
+ f"- **Source:** {data.get('source', '?')}\n",
+ break_loop=False
+ )
+ return Response(message=f"Product not found for barcode: {barcode}", break_loop=False)
+
+ async def _open_drawer(self):
+ result = await self._api_post("/api/cups/cash-drawer", {"printer": "NuPrint-210", "drawer": 1})
+ return Response(message="Cash drawer opened.", break_loop=False)
+
+ async def _clear_queue(self, kwargs):
+ source = kwargs.get("source", "")
+ result = await self._api_delete(f"/api/queue/sources/{quote(source)}/jobs")
+ return Response(message=f"Cleared queue for source: {source}", break_loop=False)
+
+ def _format_print_result(self, result, label):
+ if isinstance(result, dict):
+ job_id = result.get("jobId", "?")
+ preview_url = f"{PRINT_WEB_URL}/api/print/jobs/{job_id}/preview"
+ msg = f"**{label} printed** (Job: `{job_id[:8]}...`)\n\n"
+ msg += f"\n"
+ return Response(message=msg, break_loop=False)
+ return Response(message=f"{label}: {result}", break_loop=False)
+
+ # --- PRINT-MEGA Sprint: New actions (MCP endpoints must exist first) ---
+
+ async def _ai_summary(self, kwargs):
+ """Generate AI summary of text or URL content, optionally print."""
+ payload = {
+ "text": kwargs.get("text", ""),
+ "url": kwargs.get("url", ""),
+ "printResult": kwargs.get("print_result", False),
+ "model": kwargs.get("model", ""),
+ }
+ result = await self._api_post("/api/mcp/ai-summary", payload)
+ if isinstance(result, dict):
+ summary = result.get("summary", "No summary generated.")
+ printed = " (printed)" if result.get("printed") else ""
+ return Response(
+ message=f"## AI Summary{printed}\n\n{summary}",
+ break_loop=False
+ )
+ return Response(message=f"AI Summary: {result}", break_loop=False)
+
+ async def _recipe_print_enhanced(self, kwargs):
+ """Enhanced recipe print with Selenium Grid fallback for JS-rendered sites."""
+ payload = {
+ "url": kwargs.get("url", ""),
+ "useSelenium": kwargs.get("use_selenium", True),
+ }
+ result = await self._api_post("/api/mcp/recipe", payload)
+ return self._format_print_result(result, "Recipe (enhanced)")
+
+ async def _product_search(self, kwargs):
+ """Search for product by name/description via Ollama + SQLite cache."""
+ query = kwargs.get("query", kwargs.get("barcode", ""))
+ data = await self._api_get(f"/api/mcp/product-lookup?q={quote(query)}")
+ if isinstance(data, dict) and data.get("name"):
+ cached = " (cached)" if data.get("cached") else ""
+ img = ""
+ if data.get("imageUrl"):
+ img = f"\n\n"
+ return Response(
+ message=f"## Product: {data.get('name', '?')}{cached}{img}\n"
+ f"- **Brand:** {data.get('brand', '?')}\n"
+ f"- **Barcode:** {data.get('barcode', '?')}\n"
+ f"- **Category:** {data.get('category', '?')}\n"
+ f"- **Source:** {data.get('source', '?')}\n"
+ f"- **AI Summary:** {data.get('aiSummary', 'N/A')}\n",
+ break_loop=False
+ )
+ return Response(message=f"No product found for: {query}", break_loop=False)
+
+ # --- HTTP helpers ---
+
+ async def _api_get(self, path):
+ import aiohttp
+ headers = {}
+ if PRINT_WEB_API_KEY:
+ headers["X-Api-Key"] = PRINT_WEB_API_KEY
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"{PRINT_WEB_URL}{path}", headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
+ if resp.status == 200:
+ return await resp.json()
+ return f"HTTP {resp.status}: {await resp.text()}"
+
+ async def _api_post(self, path, payload):
+ import aiohttp
+ headers = {"Content-Type": "application/json"}
+ if PRINT_WEB_API_KEY:
+ headers["X-Api-Key"] = PRINT_WEB_API_KEY
+ async with aiohttp.ClientSession() as session:
+ async with session.post(f"{PRINT_WEB_URL}{path}", json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status == 200:
+ try:
+ return await resp.json()
+ except:
+ return await resp.text()
+ return f"HTTP {resp.status}: {await resp.text()}"
+
+ async def _api_delete(self, path):
+ import aiohttp
+ headers = {}
+ if PRINT_WEB_API_KEY:
+ headers["X-Api-Key"] = PRINT_WEB_API_KEY
+ async with aiohttp.ClientSession() as session:
+ async with session.delete(f"{PRINT_WEB_URL}{path}", headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
+ if resp.status == 200:
+ try:
+ return await resp.json()
+ except:
+ return await resp.text()
+ return f"HTTP {resp.status}: {await resp.text()}"
+ qrcode_generator.py: |
+ # QR Code Generator Tool
+ # Generates QR codes as PNG/SVG images using Python's qrcode library with Pillow,
+ # falling back to qrencode CLI or pure-Python SVG generation.
+ # Decodes QR codes from images using zbarimg.
+ # Air-gap safe: prefers pure-Python approaches, installs packages locally if needed.
+
+ import subprocess
+ import os
+ import json
+ from pathlib import Path
+
+ from python.helpers.tool import Tool, Response
+
+
+ class QrcodeGenerator(Tool):
+ async def execute(self, **kwargs) -> Response:
+ """
+ Generate and decode QR codes.
+
+ Args:
+ action (str): The action to perform. Required.
+ Options: "generate", "generate_svg", "generate_batch", "decode", "info"
+ data (str): The data to encode in the QR code.
+ Required for: generate, generate_svg.
+ output_path (str): Output file path. Default: /tmp/qrcode.png.
+ Used by: generate.
+ output_dir (str): Output directory for batch generation. Default: /tmp/qrcodes/.
+ Used by: generate_batch.
+ items (list): List of {data, filename} dicts for batch generation.
+ Required for: generate_batch.
+ image_path (str): Path to QR code image to decode.
+ Required for: decode.
+ size (int): Module size in pixels. Default: 10 for generate, 8 for generate_svg.
+ border (int): Border size in modules. Default: 4.
+ format (str): Output format: "png" or "svg". Default: "png".
+ Used by: generate.
+
+ Returns:
+ QR code generation/decode results formatted as markdown.
+ """
+ action = self.args.get("action", "")
+ data = self.args.get("data", "")
+ output_path = self.args.get("output_path", "")
+ output_dir = self.args.get("output_dir", "/tmp/qrcodes")
+ items = self.args.get("items", [])
+ image_path = self.args.get("image_path", "")
+ size = int(self.args.get("size", 0))
+ border = int(self.args.get("border", 4))
+ fmt = self.args.get("format", "png").lower()
+
+ if not action:
+ return Response(message=_show_usage(), break_loop=False)
+
+ valid_actions = ["generate", "generate_svg", "generate_batch", "decode", "info"]
+ if action not in valid_actions:
+ return Response(message=f"Error: Invalid action '{action}'. Valid actions: {', '.join(valid_actions)}", break_loop=False)
+
+ if action == "generate":
+ if not data:
+ return Response(message="Error: `data` is required for generate action.", break_loop=False)
+ if not output_path:
+ ext = "svg" if fmt == "svg" else "png"
+ output_path = f"/tmp/qrcode.{ext}"
+ if not size:
+ size = 10
+ if fmt == "svg":
+ return Response(message=_generate_svg_file(data, output_path, size, border), break_loop=False)
+ return Response(message=_generate_png(data, output_path, size, border), break_loop=False)
+
+ if action == "generate_svg":
+ if not data:
+ return Response(message="Error: `data` is required for generate_svg action.", break_loop=False)
+ if not size:
+ size = 8
+ return Response(message=_generate_svg_inline(data, size, border), break_loop=False)
+
+ if action == "generate_batch":
+ if not items:
+ return Response(message="Error: `items` is required for generate_batch action. Provide a list of {data, filename} dicts.", break_loop=False)
+ if isinstance(items, str):
+ try:
+ items = json.loads(items)
+ except json.JSONDecodeError:
+ return Response(message="Error: `items` must be a JSON list of {data, filename} dicts.", break_loop=False)
+ return Response(message=_generate_batch(items, output_dir, size or 10, border), break_loop=False)
+
+ if action == "decode":
+ if not image_path:
+ return Response(message="Error: `image_path` is required for decode action.", break_loop=False)
+ return Response(message=_decode(image_path), break_loop=False)
+
+ if action == "info":
+ return Response(message=_show_info(), break_loop=False)
+
+ return Response(message=f"Error: Action '{action}' not implemented.", break_loop=False)
+
+
+ def _generate_png(data: str, output_path: str, size: int, border: int) -> str:
+ """Generate a QR code PNG image."""
+
+ # Strategy 1: Python qrcode library
+ result = _try_python_qrcode_png(data, output_path, size, border)
+ if result:
+ return result
+
+ # Strategy 2: qrencode CLI
+ result = _try_qrencode_cli(data, output_path, size, border, "PNG")
+ if result:
+ return result
+
+ # Strategy 3: Install qrcode library and retry
+ result = _try_install_and_generate(data, output_path, size, border)
+ if result:
+ return result
+
+ return _generation_failed_message(data)
+
+
+ def _try_python_qrcode_png(data: str, output_path: str, size: int, border: int) -> str:
+ """Try generating QR with Python qrcode + Pillow."""
+ script = f'''
+ import sys
+ try:
+ import qrcode
+ qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M,
+ box_size={size}, border={border})
+ qr.add_data({repr(data)})
+ qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white")
+ img.save({repr(output_path)})
+ print("OK")
+ except ImportError:
+ print("MISSING_LIB")
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR:{{e}}")
+ sys.exit(2)
+ '''
+ try:
+ result = subprocess.run(
+ ["python3", "-c", script],
+ capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode == 0 and "OK" in result.stdout:
+ file_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
+ return _format_generate_result(data, output_path, "png", file_size)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+ return ""
+
+
+ def _try_qrencode_cli(data: str, output_path: str, size: int, border: int, fmt: str) -> str:
+ """Try generating QR with qrencode CLI."""
+ cmd = [
+ "qrencode", "-o", output_path,
+ "-s", str(size), "-m", str(border),
+ "-t", fmt, data,
+ ]
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ if result.returncode == 0 and os.path.exists(output_path):
+ file_size = os.path.getsize(output_path)
+ return _format_generate_result(data, output_path, fmt.lower(), file_size)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+ return ""
+
+
+ def _try_install_and_generate(data: str, output_path: str, size: int, border: int) -> str:
+ """Try installing qrcode library and generating."""
+ try:
+ install_result = subprocess.run(
+ ["pip3", "install", "--quiet", "qrcode[pil]"],
+ capture_output=True, text=True, timeout=60,
+ )
+ if install_result.returncode == 0:
+ return _try_python_qrcode_png(data, output_path, size, border)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+
+ # Try pip instead of pip3
+ try:
+ install_result = subprocess.run(
+ ["pip", "install", "--quiet", "qrcode[pil]"],
+ capture_output=True, text=True, timeout=60,
+ )
+ if install_result.returncode == 0:
+ return _try_python_qrcode_png(data, output_path, size, border)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+
+ return ""
+
+
+ def _generate_svg_file(data: str, output_path: str, size: int, border: int) -> str:
+ """Generate a QR code as SVG file."""
+
+ # Strategy 1: Python qrcode with SVG factory
+ result = _try_python_qrcode_svg_file(data, output_path, size, border)
+ if result:
+ return result
+
+ # Strategy 2: qrencode CLI with SVG output
+ result = _try_qrencode_cli(data, output_path, size, border, "SVG")
+ if result:
+ return result
+
+ # Strategy 3: Pure Python SVG
+ svg_content = _pure_python_svg(data, size, border)
+ if svg_content:
+ os.makedirs(os.path.dirname(output_path) or "/tmp", exist_ok=True)
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write(svg_content)
+ file_size = os.path.getsize(output_path)
+ return _format_generate_result(data, output_path, "svg", file_size)
+
+ return _generation_failed_message(data)
+
+
+ def _try_python_qrcode_svg_file(data: str, output_path: str, size: int, border: int) -> str:
+ """Try generating SVG QR with Python qrcode library."""
+ script = f'''
+ import sys
+ try:
+ import qrcode
+ import qrcode.image.svg
+ qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M,
+ box_size={size}, border={border})
+ qr.add_data({repr(data)})
+ qr.make(fit=True)
+ img = qr.make_image(image_factory=qrcode.image.svg.SvgImage)
+ img.save({repr(output_path)})
+ print("OK")
+ except ImportError:
+ print("MISSING_LIB")
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR:{{e}}")
+ sys.exit(2)
+ '''
+ try:
+ result = subprocess.run(
+ ["python3", "-c", script],
+ capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode == 0 and "OK" in result.stdout:
+ file_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
+ return _format_generate_result(data, output_path, "svg", file_size)
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+ return ""
+
+
+ def _generate_svg_inline(data: str, size: int, border: int) -> str:
+ """Generate QR code as inline SVG markup."""
+
+ # Strategy 1: Python qrcode library
+ script = f'''
+ import sys
+ try:
+ import qrcode
+ import qrcode.image.svg
+ from io import BytesIO
+ qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M,
+ box_size={size}, border={border})
+ qr.add_data({repr(data)})
+ qr.make(fit=True)
+ img = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage)
+ buf = BytesIO()
+ img.save(buf)
+ print(buf.getvalue().decode("utf-8"))
+ except ImportError:
+ print("MISSING_LIB")
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR:{{e}}")
+ sys.exit(2)
+ '''
+ try:
+ result = subprocess.run(
+ ["python3", "-c", script],
+ capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode == 0 and "MISSING_LIB" not in result.stdout:
+ svg = result.stdout.strip()
+ if svg.startswith(" str:
+ """Generate a QR code SVG using pure Python (no external libraries).
+
+ This is a minimal QR encoder supporting alphanumeric and byte mode
+ for short data strings. For longer data, it returns an empty string
+ and the caller should fall back to CLI tools.
+
+ Uses a subprocess to invoke Python's qrcode module if available,
+ otherwise generates a placeholder SVG indicating generation is not
+ possible without external tools.
+ """
+ # Try qrcode module one more time as a library call via subprocess
+ script = f'''
+ import sys
+ try:
+ import qrcode
+ qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M,
+ box_size=1, border={border})
+ qr.add_data({repr(data)})
+ qr.make(fit=True)
+ matrix = qr.modules
+ size = len(matrix)
+ px = {module_size}
+ total = size * px
+ parts = []
+ parts.append(f'')
+ print("\\n".join(parts))
+ except ImportError:
+ sys.exit(1)
+ except Exception as e:
+ sys.exit(2)
+ '''
+ try:
+ result = subprocess.run(
+ ["python3", "-c", script],
+ capture_output=True, text=True, timeout=30,
+ )
+ if result.returncode == 0 and "