diff --git a/apps/agent-zero/configmaps-bluejay.yaml b/apps/agent-zero/configmaps-bluejay.yaml
index d5c0974..22701a2 100644
--- a/apps/agent-zero/configmaps-bluejay.yaml
+++ b/apps/agent-zero/configmaps-bluejay.yaml
@@ -1,16170 +1,16295 @@
-# 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,
- })
- curl_headers = ["-H", "Content-Type: application/json"]
- if os.environ.get("FC_LLM_BRIDGE_API_KEY"):
- curl_headers.extend(["-H", f"X-Api-Key: {os.environ['FC_LLM_BRIDGE_API_KEY']}"])
-
- try:
- result = subprocess.run(
- [
- "curl", "-s", "--max-time", "120",
- "-X", "POST",
- f"{api_base}/api/generate",
- *curl_headers,
- "-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 "