# Blue Jay Profile ConfigMaps for Agent Zero NUC
# Generated 2026-04-08 — source: scripts/agent-zero/
# Tools split into 3 ConfigMaps to stay under K8s annotation limit (262K)
---
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"`.
"""
kind: ConfigMap
metadata:
name: bluejay-tools-a
namespace: agent-zero
---
apiVersion: v1
data:
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.
"""
kind: ConfigMap
metadata:
name: bluejay-tools-b
namespace: agent-zero
---
apiVersion: v1
data:
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 "