parent
7d49a25547
commit
b7319dced6
@ -1,53 +0,0 @@
|
||||
<h1 align="center">
|
||||
<a name="logo" href="https://www.vCloudInfo.com/tag/iot"><img src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/x_profile.png" alt="Bear Stone Smart Home" width="200"></a>
|
||||
<br>
|
||||
Bear Stone Smart Home Documentation
|
||||
</h1>
|
||||
<h4 align="center">Be sure to :star: my configuration repo so you can keep up to date on any daily progress!</h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://x.com/ccostan)
|
||||
[](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
|
||||
</div>
|
||||
|
||||
Codex skills stored in-repo so they can be shared with the community. These are documentation + helper scripts only (no secrets).
|
||||
|
||||
### Walkthroughs
|
||||
- Dashboard design skill: [](https://youtu.be/aFis2YPeSuY)
|
||||
- Companion post: [](https://www.vcloudinfo.com/2026/02/home-assistant-dashboard-design-system-button-card.html)
|
||||
|
||||
### Quick navigation
|
||||
- You are here: `codex_skills/`
|
||||
- [Repo overview](../README.md) | [Dashboards](../config/dashboards/README.md) | [Issues](https://github.com/CCOSTAN/Home-AssistantConfig/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc)
|
||||
|
||||
## Skills
|
||||
|
||||
- `homeassistant-dashboard-designer/`: Constrained, button-card-first Lovelace dashboard design system + YAML lint helper.
|
||||
- `homeassistant-yaml-dry-verifier/`: Home Assistant YAML DRY verifier to detect redundant triggers/conditions/actions/sequence blocks and suggest refactors.
|
||||
- `infrastructure-doc-sync/`: Session closeout workflow to update AGENTS/README/Dashy shortcuts/BearClaw infrastructure snapshot consistently after infra changes.
|
||||
- `network-architecture-diagrammer/`: Mermaid-first homelab/network architecture diagram workflow for Excalidraw-friendly topology and service maps.
|
||||
|
||||
### Notes
|
||||
- Codex loads skills from your local Codex home. See each skill folder for install steps.
|
||||
|
||||
**All of my configuration files are tested against the most stable version of home-assistant.**
|
||||
|
||||
<a name="bottom" href="https://github.com/CCOSTAN/Home-AssistantConfig#logo"><img align="right" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/up_arrow.png" width="25" ></a>
|
||||
|
||||
**Still have questions on my Config?** <br>
|
||||
**Message me on X :** [](https://www.x.com/ccostan)
|
||||
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">You can buy me a coffee</span></a><a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"></a>
|
||||
<br>
|
||||
<a href="https://eepurl.com/dmXFYz"><img align="center" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/email_link.png" height="50" ></a><br>
|
||||
<a href="https://www.vCloudInfo.com/p/affiliate-disclosure.html">
|
||||
Affiliate Disclosure
|
||||
</a></p>
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
<h1 align="center">
|
||||
<a name="logo" href="https://www.vCloudInfo.com/tag/iot"><img src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/x_profile.png" alt="Bear Stone Smart Home" width="200"></a>
|
||||
<br>
|
||||
Bear Stone Smart Home Documentation
|
||||
</h1>
|
||||
<h4 align="center">Be sure to :star: my configuration repo so you can keep up to date on any daily progress!</h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://x.com/ccostan)
|
||||
[](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
|
||||
</div>
|
||||
|
||||
# Home Assistant Dashboard Designer (Codex Skill)
|
||||
|
||||
This directory contains the `homeassistant-dashboard-designer` Codex skill, stored in-repo so it can be shared with the community.
|
||||
|
||||
### Walkthrough
|
||||
- Video: [](https://youtu.be/aFis2YPeSuY)
|
||||
- Companion post: [](https://www.vcloudinfo.com/2026/02/home-assistant-dashboard-design-system-button-card.html)
|
||||
|
||||
### Quick navigation
|
||||
- You are here: `codex_skills/homeassistant-dashboard-designer/`
|
||||
- [Repo overview](../../README.md) | [Codex skills](../README.md) | [Dashboards](../../config/dashboards/README.md) | [Issues](https://github.com/CCOSTAN/Home-AssistantConfig/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
- Enforces a constrained Lovelace design system (button-card first, minimal card-mod, grid/vertical-stack layout rules).
|
||||
- Encourages centralized templates and deterministic YAML output.
|
||||
- Treats Stitch MCP output as inspiration only and translates ideas into safe Lovelace YAML.
|
||||
|
||||
Skill source of truth inside this folder:
|
||||
- `SKILL.md`
|
||||
- `agents/openai.yaml`
|
||||
- `references/dashboard_rules.md`
|
||||
- `scripts/validate_lovelace_view.py`
|
||||
|
||||
## Install (Local Codex)
|
||||
|
||||
Codex loads skills from your local Codex skills directory.
|
||||
|
||||
1. Copy this folder into your Codex skills directory as:
|
||||
- `~/.codex/skills/homeassistant-dashboard-designer/` (Linux/macOS)
|
||||
- `%USERPROFILE%\\.codex\\skills\\homeassistant-dashboard-designer\\` (Windows)
|
||||
2. Restart your Codex session/editor so it re-indexes skills.
|
||||
|
||||
## Stitch MCP Install (Design Inspiration)
|
||||
|
||||
Stitch is optional and used only for design inspiration. To enable it, add a Stitch MCP server entry to your Codex config.
|
||||
|
||||
1. Set an environment variable with your API key:
|
||||
- `STITCH_API_KEY=<your_key>`
|
||||
2. Add this to your `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.stitch]
|
||||
url = "https://stitch.googleapis.com/mcp"
|
||||
env_http_headers = { "X-Goog-Api-Key" = "STITCH_API_KEY" }
|
||||
http_headers = { "Accept" = "application/json" }
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Invoke in chat:
|
||||
- `$homeassistant-dashboard-designer`
|
||||
|
||||
Then describe what you want in natural language (what to change + where + any constraints). The skill will infer the structured intent internally and enforce the button-card-first / layout constraints defined in `SKILL.md`.
|
||||
|
||||
Workflow notes:
|
||||
- This skill uses direct updates for `config/dashboards/**` (no staged rollout workflow in-skill).
|
||||
- It requires post-edit validation in this order:
|
||||
- `pwsh -NoProfile -File tools/validate_dashboards.ps1`
|
||||
- `pwsh -NoProfile -File tools/ha_ui_smoke.ps1`
|
||||
- `python codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py <changed-view.yaml>`
|
||||
|
||||
Examples:
|
||||
- "Refactor `config/dashboards/infrastructure/partials/mariadb_sections.yaml` to match the existing Infrastructure design language. Preserve existing templates and keep diffs small."
|
||||
- "Add a new Infrastructure view for Docker containers using the same layout rules as the other views (4 columns desktop / 2 columns mobile)."
|
||||
|
||||
Optional:
|
||||
- If you already have an entity list, include it.
|
||||
- If you do not, enable the Home Assistant MCP so Codex can validate entity IDs/services against your live HA instance (recommended).
|
||||
|
||||
## Home Assistant MCP (Built-In) Enablement
|
||||
|
||||
The Home Assistant MCP lets Codex validate entity IDs and service calls against your *real* HA instance before it edits YAML.
|
||||
|
||||
1. Create a Home Assistant Long-Lived Access Token (Profile page in HA).
|
||||
2. Set an environment variable (do not commit tokens):
|
||||
- `HOMEASSISTANT_MCP_AUTH=Bearer <your_long_lived_access_token>`
|
||||
3. Add this to your `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.homeassistant]
|
||||
url = "http://<your-ha-host>:8123/api/mcp"
|
||||
env_http_headers = { "Authorization" = "HOMEASSISTANT_MCP_AUTH" }
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use `https://` if your HA is behind TLS.
|
||||
- Keep tokens in environment variables, not in files under git.
|
||||
|
||||
### Notes
|
||||
- This skill intentionally contains no secrets. Configure MCP credentials via environment variables in your local Codex setup.
|
||||
|
||||
**All of my configuration files are tested against the most stable version of home-assistant.**
|
||||
|
||||
<a name="bottom" href="https://github.com/CCOSTAN/Home-AssistantConfig#logo"><img align="right" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/up_arrow.png" width="25" ></a>
|
||||
|
||||
**Still have questions on my Config?** <br>
|
||||
**Message me on X :** [](https://www.x.com/ccostan)
|
||||
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">You can buy me a coffee</span></a><a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"></a>
|
||||
<br>
|
||||
<a href="https://eepurl.com/dmXFYz"><img align="center" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/email_link.png" height="50" ></a><br>
|
||||
<a href="https://www.vCloudInfo.com/p/affiliate-disclosure.html">
|
||||
Affiliate Disclosure
|
||||
</a></p>
|
||||
@ -1,221 +0,0 @@
|
||||
---
|
||||
name: homeassistant-dashboard-designer
|
||||
description: "Design, update, and refactor Home Assistant Lovelace dashboards (YAML views/partials) with a constrained, machine-safe design system: button-card-first structure, minimal card-mod styling, optional flex-horseshoe + mini-graph telemetry, strict grid/vertical-stack layout rules, centralized templates, deterministic ordering, and config validation. Use for Home Assistant dashboard work (especially config/dashboards/**), when refactoring views, adding infra/home/energy/environment panels, or translating Stitch design inspiration into safe Lovelace YAML."
|
||||
---
|
||||
|
||||
# Home Assistant Dashboard Designer
|
||||
|
||||
Design dashboards as systems: predictable structure, reusable templates, minimal drift. Treat Stitch as inspiration only; translate to safe Lovelace YAML.
|
||||
|
||||
## Primary Input: Natural Language (Default)
|
||||
|
||||
You can give natural-language guidance. The skill must infer the structured intent internally (dashboard intent, view name, entity mapping, constraints) while enforcing the design system and validating entities/services via the Home Assistant MCP when available.
|
||||
|
||||
Minimum helpful info to include in natural language:
|
||||
- What to change (add/remove/refactor) and why (the goal).
|
||||
- Where to change it: the exact view/partial path(s) under `config/dashboards/**`.
|
||||
- Any constraints you care about: desktop/mobile columns, time window preferences for graphs, "do not touch" templates/sections.
|
||||
|
||||
If any of the above is missing, ask targeted clarifying questions (do not demand a full "intent block").
|
||||
|
||||
Example natural-language request:
|
||||
- "Refactor `config/dashboards/infrastructure/views/06_mariadb.yaml` to match the button-card-first system used in other Infrastructure views. Keep the same entities, no new templates, 4 columns desktop / 2 columns mobile."
|
||||
|
||||
## Optional Input: Structured Intent (Allowed, Not Required)
|
||||
|
||||
If the user provides structured intent, accept it. Do not require it.
|
||||
|
||||
```yaml
|
||||
dashboard_type: infra # infra|home|energy|environment
|
||||
view_name: "Infrastructure Overview"
|
||||
target_paths:
|
||||
dashboard_yaml: "config/dashboards/infrastructure/dashboard.yaml"
|
||||
view_yaml: "config/dashboards/infrastructure/views/07_docker_containers.yaml"
|
||||
entities:
|
||||
- name: "Proxmox01"
|
||||
cpu: "sensor.proxmox01_cpu"
|
||||
memory: "sensor.proxmox01_mem"
|
||||
constraints:
|
||||
desktop_columns: 4
|
||||
mobile_columns: 2
|
||||
notes:
|
||||
- "Preserve existing templates; minimize diff."
|
||||
```
|
||||
|
||||
If updating an existing view and not obvious from context, ask:
|
||||
|
||||
- Which view file (single view dict) is the source of truth?
|
||||
- Any "don't touch" areas/templates?
|
||||
- Any fallback cards already in use that must remain?
|
||||
|
||||
## Output Contract (Always Produce)
|
||||
|
||||
- One valid Lovelace view (single view dict)
|
||||
- Deterministic ordering (stable ordering of cards/sections)
|
||||
- Section comments (short, consistent)
|
||||
- Zero per-card styling variance (no one-off styles; use templates/snippets)
|
||||
- Fallback usage explicitly justified (YAML comments adjacent to the fallback card)
|
||||
|
||||
## Card Priority Model
|
||||
|
||||
Attempt Tier 1 first. Use Tier 2 only if Tier 1 cannot express the requirement safely.
|
||||
|
||||
Tier 1 (primary):
|
||||
- `custom:button-card` (structure + tiles + headers + containers)
|
||||
- `card-mod` (shared/global polish only; shared snippets OK)
|
||||
- `custom:flex-horseshoe-card` (single-metric radial telemetry)
|
||||
- `custom:mini-graph-card` (single-metric trends)
|
||||
|
||||
Tier 2 (fallback, must justify in comments):
|
||||
- `entities`, `markdown`, `history-graph`, `conditional`
|
||||
- `iframe` (last resort)
|
||||
|
||||
## Layout Rules (Hard Constraints)
|
||||
|
||||
- Allowed layout containers: `grid`, `vertical-stack`
|
||||
- Maximum nesting depth: 2 (example: `grid` -> `vertical-stack` -> cards)
|
||||
- No `horizontal-stack` inside grid cells (prefer: more grid columns, or vertical-stack)
|
||||
- No freeform positioning
|
||||
- No layout logic embedded in `card-mod`
|
||||
- For dense tile lists in `type: sections` views, keep the outer panel full-width (`column_span: 4`) and make the inner tile grid responsive using `custom:layout-card` (`4` desktop / `2` mobile unless the user asks otherwise).
|
||||
- In `type: sections` views, the first section immediately below top chips/badges must be a full-width container (`column_span: 4`) with a single wrapper card that also sets `grid_options.columns: full` to prevent skinny-column whitespace.
|
||||
- Consolidate upward after removals/moves: do not leave dead space, empty placeholders, or intentionally sparse rows unless the user explicitly asks for whitespace.
|
||||
|
||||
Note: If the repo/view uses Home Assistant `type: sections`, treat `sections` as the top-level structure and enforce the same container rules inside each section (sections should contain only `grid`/`vertical-stack` cards and their children).
|
||||
|
||||
## Design System Rules (Implementation Discipline)
|
||||
|
||||
### Button Card (Primary Renderer)
|
||||
|
||||
- Use `custom:button-card` as the structural primitive.
|
||||
- Templates required. Do not inline per-card `styles:`; keep styling inside centralized templates/snippets.
|
||||
- Logic via variables or template JS only (avoid duplicating logic per-card).
|
||||
|
||||
### card-mod (Styling Constraint Layer)
|
||||
|
||||
- Allowed only for shared/global polish:
|
||||
- padding normalization
|
||||
- border radius control
|
||||
- shadow suppression
|
||||
- typography consistency
|
||||
- No entity-specific styling
|
||||
- No per-card visual experimentation
|
||||
|
||||
### Flex Horseshoe Card
|
||||
|
||||
- One primary metric per card.
|
||||
- Explicit color thresholds.
|
||||
- No mixed units.
|
||||
|
||||
### Mini Graph Card
|
||||
|
||||
- One metric per graph.
|
||||
- Consistent time windows per dashboard.
|
||||
- Minimal legends (only when required).
|
||||
|
||||
## Template Architecture (Required)
|
||||
|
||||
- Views reference templates only (for `custom:button-card`: every instance must specify `template:`).
|
||||
- Templates accept variables; views pass variables.
|
||||
- Do not create new templates unless explicitly instructed.
|
||||
|
||||
Repo reality check (important):
|
||||
- If the repo already has established templates (for example, Infrastructure uses `bearstone_*`), use what exists.
|
||||
- If the spec's "required template categories" are not present, map to the closest existing template and call out the gap, or ask for explicit permission to add templates.
|
||||
|
||||
## Stitch MCP (Inspiration Only)
|
||||
|
||||
Use Stitch only to learn:
|
||||
- visual hierarchy (headers, grouping, density)
|
||||
- spacing rhythm (padding/gaps)
|
||||
- grouping patterns (panels, sections)
|
||||
|
||||
Never treat Stitch output as implementation code.
|
||||
|
||||
When prompting Stitch, include these constraints verbatim:
|
||||
|
||||
```
|
||||
Grid-only layout. No absolute positioning. No JS frameworks. No custom HTML/CSS outside card-mod.
|
||||
Card types limited to: custom:button-card, card-mod, custom:flex-horseshoe-card, custom:mini-graph-card, and HA core fallback cards only if unavoidable.
|
||||
Max layout nesting depth: 2. No horizontal-stack inside grid cells.
|
||||
```
|
||||
|
||||
## Optional Stitch Handoff (Light Bridge)
|
||||
|
||||
Use this only when the user explicitly asks for visual ideation, layout exploration, or variants.
|
||||
|
||||
- Trigger Stitch path:
|
||||
- User asks for redesign inspiration, variants, or multiple visual directions.
|
||||
- Skip Stitch path:
|
||||
- User asks for direct dashboard refactor/update and no ideation is needed.
|
||||
- Preconditions:
|
||||
- Stitch is used for hierarchy/spacing/grouping ideation only.
|
||||
- Stitch output must never be copied as implementation code.
|
||||
- Final implementation must still obey all allowed card/layout rules in this skill.
|
||||
|
||||
Handoff artifact (summary only, no code export):
|
||||
|
||||
```yaml
|
||||
stitch_intent:
|
||||
layout_pattern: "<grid and grouping pattern to translate>"
|
||||
section_hierarchy: "<header/panel/section ordering>"
|
||||
density_target: "<compact|balanced|spacious>"
|
||||
cards_to_translate:
|
||||
- "<visual card concept mapped to allowed Lovelace card types>"
|
||||
```
|
||||
|
||||
Translation requirement:
|
||||
- Convert `stitch_intent` into approved Lovelace structures only (`grid`, `vertical-stack`, Tier 1 cards first).
|
||||
- Re-map any unsupported Stitch concepts to compliant cards/containers and explain fallback choices inline when Tier 2 is required.
|
||||
|
||||
Fallback behavior when Stitch is unavailable:
|
||||
- Continue with manual ideation using this skill's design system and rules.
|
||||
- State degraded mode clearly ("Stitch unavailable; proceeding with manual hierarchy/density planning").
|
||||
- Do not block dashboard work on Stitch availability.
|
||||
|
||||
## Workflow (Do This In Order)
|
||||
|
||||
1. Use direct-update mode for this repo:
|
||||
- Edit production YAML directly (no staged dashboard copies in this skill workflow).
|
||||
- Keep rollback safety by minimizing diffs and validating before HA restart/reload.
|
||||
2. Read the target dashboard/view/partials/templates and include targets before editing:
|
||||
- Confirm the exact source view/partial/template files.
|
||||
- Confirm referenced `!include` files/directories exist and are in-scope.
|
||||
3. Determine intent from the user's request and existing dashboard context: `infra` (NOC), `home`, `energy`, or `environment`. Keep one intent per view.
|
||||
4. Validate entities and services before editing:
|
||||
- Prefer the Home Assistant MCP for live entity/service validation (required when available).
|
||||
- Record the MCP validation step in the work notes before writing YAML.
|
||||
- If MCP is not available, ask the user to confirm entity IDs and services (do not guess).
|
||||
5. Draft layout with constraints: a top-level `grid` and optional `vertical-stack` groups.
|
||||
- If using Stitch, first summarize `stitch_intent` and treat it as advisory input to this step.
|
||||
- After removals, reflow cards/sections upward to collapse gaps and reduce empty rows.
|
||||
6. Implement using Tier 1 cards first; reuse existing templates; avoid one-off styles.
|
||||
7. If fallback cards are necessary, add an inline comment explaining why Tier 1 cannot satisfy the requirement.
|
||||
8. Validate in this order:
|
||||
- Run `pwsh -NoProfile -File tools/validate_dashboards.ps1`.
|
||||
- Run `pwsh -NoProfile -File tools/ha_ui_smoke.ps1`.
|
||||
- Run Home Assistant config validation (`ha core check` or `homeassistant --script check_config`) when available.
|
||||
- Run `scripts/validate_lovelace_view.py` from this skill against each changed view file.
|
||||
9. Failure behavior:
|
||||
- If requirements can't be met: state the violated rule and propose a compliant alternative.
|
||||
- If validation fails: stop, surface the error output, and propose corrected YAML. Do not leave invalid config applied.
|
||||
|
||||
Example flow:
|
||||
- User asks for redesign inspiration -> Stitch ideation with required constraints -> summarize `stitch_intent` -> translate to Lovelace-safe YAML -> run validation checks.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/dashboard_rules.md` when you need the full constraint set and repo-specific mapping notes.
|
||||
|
||||
## Home Assistant MCP Validation (Required When Available)
|
||||
|
||||
Before writing Lovelace YAML, confirm:
|
||||
- Every `entity:` (and any entities referenced via variables) exists.
|
||||
- Every service you plan to call exists and the payload shape is correct (especially `service_data`).
|
||||
|
||||
When Home Assistant MCP is enabled, use it to:
|
||||
- List/confirm entities (avoid typos like `sensor.foo_bar` vs `sensor.foobar`).
|
||||
- Confirm the dashboard card references match real entities.
|
||||
- Validate service names and fields before committing YAML changes.
|
||||
|
||||
If MCP is not enabled or not reachable, stop and ask for the missing entity/service IDs rather than inventing them.
|
||||
@ -1,13 +0,0 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Dashboard Designer Agent - Codex skill entry metadata.
|
||||
# Defines the OpenAI-facing launcher text for this local skill.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
interface:
|
||||
display_name: "Home Assistant Dashboard Designer"
|
||||
short_description: "Design/refactor HA Lovelace dashboards safely"
|
||||
default_prompt: "Use $homeassistant-dashboard-designer to design, update, or refactor a Home Assistant Lovelace dashboard view using button-card templates + safe grid/stack layouts."
|
||||
@ -1,169 +0,0 @@
|
||||
# Dashboard Rules (Full)
|
||||
|
||||
This file is reference material for $homeassistant-dashboard-designer. Prefer following the workflow in `../SKILL.md`, and read this only when you need details.
|
||||
|
||||
## Card Priority Model
|
||||
|
||||
Tier 1 (primary, attempt first):
|
||||
- `custom:button-card`
|
||||
- `card-mod` (shared/global styling only)
|
||||
- `custom:flex-horseshoe-card`
|
||||
- `custom:mini-graph-card`
|
||||
|
||||
Tier 2 (fallback, only when Tier 1 cannot satisfy the requirement; justify inline):
|
||||
- `entities`
|
||||
- `markdown`
|
||||
- `history-graph`
|
||||
- `conditional`
|
||||
- `iframe` (last resort)
|
||||
|
||||
## Layout Rules (Hard Constraints)
|
||||
|
||||
- Allowed layout containers: `grid`, `vertical-stack`
|
||||
- Maximum nesting depth: 2
|
||||
- No `horizontal-stack` inside grid cells
|
||||
- No freeform positioning
|
||||
- No layout logic embedded in `card-mod`
|
||||
- For dense status/container lists in `type: sections` views, keep the panel full-width (`column_span: 4`) and use a responsive inner grid (`4` desktop / `2` mobile by default).
|
||||
- In `type: sections` views, always place a full-width wrapper section directly below top chips/badges and set the wrapper card to `grid_options.columns: full`.
|
||||
- Consolidate layout vertically after any move/remove; dead space is disallowed unless explicitly requested.
|
||||
|
||||
Sections-mode note:
|
||||
- If a view uses `type: sections`, treat `sections` as the top-level structure and enforce the same container rules inside each section.
|
||||
- Prefer reducing section count and regrouping cards into earlier rows rather than leaving sparse trailing space.
|
||||
|
||||
## Template Architecture (Required)
|
||||
|
||||
Intent: centralize visuals and logic into templates; views pass only variables and remain uniform.
|
||||
|
||||
Rules:
|
||||
- `custom:button-card` must use templates (no one-off per-card styles).
|
||||
- Templates accept variables.
|
||||
- Do not hardcode entity IDs inside templates.
|
||||
- Do not create new templates without explicit instruction.
|
||||
|
||||
Reality note for CCOSTAN/Home-AssistantConfig:
|
||||
- Infrastructure templates already exist in:
|
||||
- `config/dashboards/infrastructure/templates/button_card_templates.yaml`
|
||||
- Existing template naming uses `bearstone_*` and includes styling inside templates (this is OK).
|
||||
- When asked to implement new conceptual categories (e.g., "status_pill"), first map to existing templates (e.g., `bearstone_infra_chip*`). If no suitable match exists, ask before adding templates.
|
||||
|
||||
## Design System (Discipline)
|
||||
|
||||
### Button Card
|
||||
|
||||
Use it for:
|
||||
- Infrastructure tiles
|
||||
- Status pills
|
||||
- Section headers
|
||||
- Control footers
|
||||
- Card containers
|
||||
|
||||
Rules:
|
||||
- Templates required.
|
||||
- Avoid per-card `styles:` keys.
|
||||
- Avoid per-card `card_mod:` blocks.
|
||||
|
||||
### card-mod
|
||||
|
||||
Allowed only for shared/global polish:
|
||||
- padding normalization
|
||||
- border radius control
|
||||
- shadow suppression
|
||||
- typography consistency
|
||||
|
||||
Not allowed:
|
||||
- entity-specific styling
|
||||
- per-card visual experimentation
|
||||
|
||||
### Flex Horseshoe Card
|
||||
|
||||
Use it for:
|
||||
- Temperature
|
||||
- Battery
|
||||
- Load
|
||||
- Utilization
|
||||
- Percentage-based metrics
|
||||
|
||||
Rules:
|
||||
- One primary metric per card.
|
||||
- Explicit color thresholds.
|
||||
- No mixed units.
|
||||
|
||||
### Mini Graph Card
|
||||
|
||||
Rules:
|
||||
- One metric per graph.
|
||||
- Consistent time windows per dashboard (keep uniform unless user asks otherwise).
|
||||
- Minimal legends (only if required for interpretation).
|
||||
|
||||
## Stitch MCP Integration (Inspiration Only)
|
||||
|
||||
Stitch is advisory, never authoritative.
|
||||
|
||||
When using Stitch:
|
||||
- Provide feasibility constraints (cards + layout)
|
||||
- Use outputs only to inform hierarchy/grouping/density/spacing
|
||||
- Translate into approved Lovelace YAML
|
||||
|
||||
Required constraints to include in Stitch prompt:
|
||||
|
||||
```
|
||||
Grid-only layout. No absolute positioning. No JS frameworks. No custom HTML/CSS outside card-mod.
|
||||
Card types limited to: custom:button-card, card-mod, custom:flex-horseshoe-card, custom:mini-graph-card, and HA core fallback cards only if unavoidable.
|
||||
Max layout nesting depth: 2. No horizontal-stack inside grid cells.
|
||||
```
|
||||
|
||||
## Optional Stitch Handoff (Light Bridge)
|
||||
|
||||
Decision rule:
|
||||
- Use Stitch only when the user requests visual ideation, variants, or exploration.
|
||||
- For normal refactor/update requests, skip Stitch and implement directly with this skill's rules.
|
||||
|
||||
Handoff artifact (advisory summary, not implementation code):
|
||||
|
||||
```yaml
|
||||
stitch_intent:
|
||||
layout_pattern: "<grid and grouping pattern to translate>"
|
||||
section_hierarchy: "<header/panel/section ordering>"
|
||||
density_target: "<compact|balanced|spacious>"
|
||||
cards_to_translate:
|
||||
- "<visual card concept mapped to allowed Lovelace card types>"
|
||||
```
|
||||
|
||||
Required translation back to Lovelace:
|
||||
- Treat `stitch_intent` as input to layout planning only.
|
||||
- Implement using approved containers/cards and template architecture from this document.
|
||||
- For any unsupported concept, re-map to compliant cards and justify Tier 2 fallback inline.
|
||||
|
||||
Degraded mode:
|
||||
- If Stitch or Stitch skills are unavailable, continue with manual hierarchy/spacing ideation.
|
||||
- Do not block dashboard work on Stitch availability.
|
||||
|
||||
Anti-drift checklist:
|
||||
- No HTML/CSS export artifacts from Stitch output.
|
||||
- No nesting-depth violations (max 2).
|
||||
- No card-type drift outside approved Tier 1/Tier 2 lists.
|
||||
|
||||
## Repo-Specific Dashboard YAML Rules (config/dashboards/**)
|
||||
|
||||
If working in this repo's `config/dashboards/` tree:
|
||||
- Do not edit `config/.storage` (runtime state).
|
||||
- Use direct-update workflow for dashboard YAML in this repo (no staged dashboard promotion flow in this skill).
|
||||
- Includes must use absolute container paths starting with `/config/`.
|
||||
- Views are one file per view, and the dashboard file uses `!include_dir_list`.
|
||||
- Files under `config/dashboards/**/*.yaml` must include the standard `@CCOSTAN` header block.
|
||||
|
||||
## Validation (Home Assistant MCP)
|
||||
|
||||
When available, use the Home Assistant MCP to validate:
|
||||
- Entity IDs referenced by Lovelace cards/templates.
|
||||
- Service names and payload fields used by actions (for example, `button.press`, `script.*`, etc.).
|
||||
|
||||
If MCP is not available, do not guess entity IDs. Ask the user to confirm them.
|
||||
|
||||
## Validation Chain (Required Before Restart/Reload)
|
||||
|
||||
- Run `pwsh -NoProfile -File tools/validate_dashboards.ps1`.
|
||||
- Run `pwsh -NoProfile -File tools/ha_ui_smoke.ps1`.
|
||||
- Run `scripts/validate_lovelace_view.py` for each changed view file.
|
||||
@ -1,379 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Lightweight validator for Lovelace view YAML files.
|
||||
|
||||
Goal: catch common violations of the homeassistant-dashboard-designer constraints
|
||||
before Home Assistant reloads.
|
||||
|
||||
This is NOT a full Lovelace schema validator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class _Tagged(str):
|
||||
pass
|
||||
|
||||
|
||||
class _Loader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def _construct_undefined(loader: _Loader, node: yaml.Node) -> Any:
|
||||
# Preserve unknown tags (e.g., !include, !include_dir_list) as opaque strings.
|
||||
if isinstance(node, yaml.ScalarNode):
|
||||
return _Tagged(f"{node.tag} {node.value}")
|
||||
if isinstance(node, yaml.SequenceNode):
|
||||
seq = loader.construct_sequence(node)
|
||||
return _Tagged(f"{node.tag} {seq!r}")
|
||||
if isinstance(node, yaml.MappingNode):
|
||||
mapping = loader.construct_mapping(node)
|
||||
return _Tagged(f"{node.tag} {mapping!r}")
|
||||
return _Tagged(f"{node.tag}")
|
||||
|
||||
|
||||
_Loader.add_constructor(None, _construct_undefined) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
level: str # "ERROR" | "WARN"
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
ALLOWED_LAYOUT = {"grid", "vertical-stack"}
|
||||
DISALLOWED_CARD_TYPES = {"horizontal-stack"}
|
||||
|
||||
# Cards allowed without justification (design-system tiering is enforced in SKILL.md; this is a lint).
|
||||
ALLOWED_CARD_TYPES = {
|
||||
"grid",
|
||||
"vertical-stack",
|
||||
"custom:button-card",
|
||||
"custom:layout-card",
|
||||
"custom:vertical-stack-in-card",
|
||||
"custom:flex-horseshoe-card",
|
||||
"custom:mini-graph-card",
|
||||
# Tier-2 fallbacks (validator does not enforce justification comments).
|
||||
"entities",
|
||||
"markdown",
|
||||
"history-graph",
|
||||
"conditional",
|
||||
"iframe",
|
||||
}
|
||||
|
||||
|
||||
def _is_mapping(v: Any) -> bool:
|
||||
return isinstance(v, dict)
|
||||
|
||||
|
||||
def _is_sequence(v: Any) -> bool:
|
||||
return isinstance(v, list)
|
||||
|
||||
|
||||
def _card_type(card: dict[str, Any]) -> str | None:
|
||||
t = card.get("type")
|
||||
if isinstance(t, str):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _validate_layout_depth(
|
||||
*,
|
||||
node: Any,
|
||||
cur_depth: int,
|
||||
max_depth: int,
|
||||
node_path: str,
|
||||
findings: list[Finding],
|
||||
) -> None:
|
||||
if not _is_mapping(node):
|
||||
return
|
||||
|
||||
ctype = _card_type(node)
|
||||
if ctype in ALLOWED_LAYOUT:
|
||||
cur_depth += 1
|
||||
if cur_depth > max_depth:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message=f"Layout nesting depth {cur_depth} exceeds max {max_depth}.",
|
||||
)
|
||||
)
|
||||
|
||||
cards = node.get("cards")
|
||||
if _is_sequence(cards):
|
||||
for idx, child in enumerate(cards):
|
||||
_validate_layout_depth(
|
||||
node=child,
|
||||
cur_depth=cur_depth,
|
||||
max_depth=max_depth,
|
||||
node_path=f"{node_path}.cards[{idx}]",
|
||||
findings=findings,
|
||||
)
|
||||
|
||||
|
||||
def _walk_cards(node: Any, node_path: str, findings: list[Finding]) -> None:
|
||||
if _is_mapping(node):
|
||||
ctype = _card_type(node)
|
||||
if ctype in DISALLOWED_CARD_TYPES:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message=f"Disallowed card type: {ctype}",
|
||||
)
|
||||
)
|
||||
if ctype and ctype not in ALLOWED_CARD_TYPES:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="WARN",
|
||||
path=node_path,
|
||||
message=f"Unknown/unlisted card type: {ctype}",
|
||||
)
|
||||
)
|
||||
|
||||
if ctype == "custom:button-card":
|
||||
tmpl = node.get("template")
|
||||
if tmpl is None:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message="custom:button-card must set template: (string or list).",
|
||||
)
|
||||
)
|
||||
if "styles" in node:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message="Per-card styles: are not allowed; move styling into centralized templates.",
|
||||
)
|
||||
)
|
||||
if "style" in node:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message="Per-card style: is not allowed; move styling into centralized templates.",
|
||||
)
|
||||
)
|
||||
if "extra_styles" in node:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message="Per-card extra_styles: is not allowed; move styling into centralized templates.",
|
||||
)
|
||||
)
|
||||
if "card_mod" in node:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=node_path,
|
||||
message="Per-card card_mod: is not allowed on button-card instances; use shared snippets or templates.",
|
||||
)
|
||||
)
|
||||
state_entries = node.get("state")
|
||||
if _is_sequence(state_entries):
|
||||
for sidx, state_entry in enumerate(state_entries):
|
||||
if _is_mapping(state_entry) and "styles" in state_entry:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path=f"{node_path}.state[{sidx}]",
|
||||
message="Per-card state styles are not allowed; move styling into centralized templates.",
|
||||
)
|
||||
)
|
||||
|
||||
cards = node.get("cards")
|
||||
if _is_sequence(cards):
|
||||
for idx, child in enumerate(cards):
|
||||
_walk_cards(child, f"{node_path}.cards[{idx}]", findings)
|
||||
|
||||
elif _is_sequence(node):
|
||||
for idx, child in enumerate(node):
|
||||
_walk_cards(child, f"{node_path}[{idx}]", findings)
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Any:
|
||||
txt = path.read_text(encoding="utf-8")
|
||||
return yaml.load(txt, Loader=_Loader)
|
||||
|
||||
|
||||
def _validate_sections_wrapper(sections: list[Any], findings: list[Finding]) -> None:
|
||||
"""Validate first sections wrapper constraints from dashboard rules."""
|
||||
if not sections:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections",
|
||||
message="sections list cannot be empty for a sections view.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
first_section = sections[0]
|
||||
if not _is_mapping(first_section):
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0]",
|
||||
message="First section must be a mapping.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
column_span = first_section.get("column_span")
|
||||
if column_span != 4:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0].column_span",
|
||||
message="First section must set column_span: 4.",
|
||||
)
|
||||
)
|
||||
|
||||
cards = first_section.get("cards")
|
||||
if not _is_sequence(cards):
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0].cards",
|
||||
message="First section must define cards as a list.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if len(cards) != 1:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0].cards",
|
||||
message="First section must contain exactly one wrapper card.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
wrapper_card = cards[0]
|
||||
if not _is_mapping(wrapper_card):
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0].cards[0]",
|
||||
message="Wrapper card must be a mapping.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
grid_options = wrapper_card.get("grid_options")
|
||||
if not _is_mapping(grid_options) or grid_options.get("columns") != "full":
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections[0].cards[0].grid_options.columns",
|
||||
message='Wrapper card must set grid_options.columns: "full".',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("view_yaml", type=Path, help="Path to a Lovelace view YAML file")
|
||||
ap.add_argument("--max-layout-depth", type=int, default=2)
|
||||
ap.add_argument("--strict", action="store_true", help="Treat WARN findings as errors (non-zero exit).")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not args.view_yaml.exists():
|
||||
print(f"ERROR: file not found: {args.view_yaml}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
doc = _load_yaml(args.view_yaml)
|
||||
except Exception as e:
|
||||
print(f"ERROR: failed to parse YAML: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
findings: list[Finding] = []
|
||||
|
||||
# The repo uses "one YAML file per view (single view dict)".
|
||||
if not _is_mapping(doc):
|
||||
findings.append(Finding(level="ERROR", path="$", message="View file must be a single YAML mapping (one view dict)."))
|
||||
else:
|
||||
# Support both classic views (`cards:`) and sections views (`type: sections`, `sections:`).
|
||||
if "cards" in doc:
|
||||
_validate_layout_depth(
|
||||
node=doc,
|
||||
cur_depth=0,
|
||||
max_depth=args.max_layout_depth,
|
||||
node_path="$",
|
||||
findings=findings,
|
||||
)
|
||||
_walk_cards(doc.get("cards", []), "$.cards", findings)
|
||||
elif doc.get("type") == "sections" and "sections" in doc:
|
||||
sections = doc.get("sections")
|
||||
if isinstance(sections, _Tagged):
|
||||
findings.append(
|
||||
Finding(
|
||||
level="WARN",
|
||||
path="$.sections",
|
||||
message="sections is an !include/unknown tag; validator cannot inspect included sections content.",
|
||||
)
|
||||
)
|
||||
elif _is_sequence(sections):
|
||||
_validate_sections_wrapper(sections, findings)
|
||||
for sidx, section in enumerate(sections):
|
||||
spath = f"$.sections[{sidx}]"
|
||||
if not _is_mapping(section):
|
||||
findings.append(Finding(level="ERROR", path=spath, message="Section must be a mapping."))
|
||||
continue
|
||||
|
||||
_validate_layout_depth(
|
||||
node=section,
|
||||
cur_depth=0,
|
||||
max_depth=args.max_layout_depth,
|
||||
node_path=spath,
|
||||
findings=findings,
|
||||
)
|
||||
_walk_cards(section.get("cards", []), f"{spath}.cards", findings)
|
||||
else:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$.sections",
|
||||
message="sections must be a list (or an !include tag).",
|
||||
)
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
Finding(
|
||||
level="ERROR",
|
||||
path="$",
|
||||
message="View dict must contain cards: or be type: sections with sections:.",
|
||||
)
|
||||
)
|
||||
|
||||
errors = [f for f in findings if f.level == "ERROR"]
|
||||
warns = [f for f in findings if f.level == "WARN"]
|
||||
|
||||
for f in errors + warns:
|
||||
print(f"{f.level}: {f.path}: {f.message}")
|
||||
|
||||
if errors:
|
||||
return 1
|
||||
if warns and args.strict:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1,127 +0,0 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "scripts" / "validate_lovelace_view.py"
|
||||
)
|
||||
MODULE_NAME = "validate_lovelace_view"
|
||||
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[MODULE_NAME] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class ValidateLovelaceViewTests(unittest.TestCase):
|
||||
def _run_validator(self, yaml_text: str, *extra_args: str) -> tuple[int, str, str]:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
view_path = Path(tmpdir) / "view.yaml"
|
||||
view_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
rc = MODULE.main([str(view_path), *extra_args])
|
||||
return rc, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_valid_classic_view_passes(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template:
|
||||
- status_chip
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertEqual(out.strip(), "")
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
def test_horizontal_stack_fails(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("Disallowed card type: horizontal-stack", out)
|
||||
|
||||
def test_button_card_missing_template_fails(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
entity: light.kitchen
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("custom:button-card must set template", out)
|
||||
|
||||
def test_sections_wrapper_rule_fails_when_full_width_missing(self) -> None:
|
||||
yaml_text = """
|
||||
title: Sections
|
||||
path: sections
|
||||
type: sections
|
||||
sections:
|
||||
- type: grid
|
||||
column_span: 4
|
||||
cards:
|
||||
- type: custom:vertical-stack-in-card
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn('Wrapper card must set grid_options.columns: "full".', out)
|
||||
|
||||
def test_sections_wrapper_rule_passes_when_full_width_wrapper_present(self) -> None:
|
||||
yaml_text = """
|
||||
title: Sections
|
||||
path: sections
|
||||
type: sections
|
||||
sections:
|
||||
- type: grid
|
||||
column_span: 4
|
||||
cards:
|
||||
- type: custom:vertical-stack-in-card
|
||||
grid_options:
|
||||
columns: "full"
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertEqual(out.strip(), "")
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
def test_unknown_include_tag_is_parse_safe(self) -> None:
|
||||
yaml_text = """
|
||||
title: Included Sections
|
||||
path: included_sections
|
||||
type: sections
|
||||
sections: !include ../sections/core.yaml
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("validator cannot inspect included sections content", out)
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,91 +0,0 @@
|
||||
<h1 align="center">
|
||||
<a name="logo" href="https://www.vCloudInfo.com/tag/iot"><img src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/x_profile.png" alt="Bear Stone Smart Home" width="200"></a>
|
||||
<br>
|
||||
Bear Stone Smart Home Documentation
|
||||
</h1>
|
||||
<h4 align="center">Be sure to :star: my configuration repo so you can keep up to date on any daily progress!</h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://x.com/ccostan)
|
||||
[](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
|
||||
</div>
|
||||
|
||||
# HA YAML DRY Verifier (Codex Skill)
|
||||
|
||||
This directory contains the `homeassistant-yaml-dry-verifier` skill and the CLI used to detect repeated YAML structures in Home Assistant automations/scripts/packages.
|
||||
|
||||
### Quick navigation
|
||||
- You are here: `codex_skills/homeassistant-yaml-dry-verifier/`
|
||||
- [Repo overview](../../README.md) | [Codex skills](../README.md) | [Packages](../../config/packages/README.md) | [Scripts](../../config/script/README.md)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
- Detects repeated `trigger`, `condition`, `action`, and `sequence` blocks.
|
||||
- Detects repeated entries inside those blocks.
|
||||
- Detects duplicate entries within a single block (`INTRA`).
|
||||
- Detects package-defined scripts called from multiple files (`CENTRAL_SCRIPT`).
|
||||
- Collapses noisy ENTRY reports when they are already fully explained by an identical `FULL_BLOCK` finding.
|
||||
- Adds workflow guardrails for automation refactors that rename/remove entity references or introduce cleanup behavior: stale-reference checks, dry-run/preview expectations, explicit confirmation, and audit/backup output.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Run on one file:
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages/bearclaw.yaml
|
||||
```
|
||||
|
||||
Run on broader scope:
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script
|
||||
```
|
||||
|
||||
Strict mode (non-zero exit if findings exist):
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script --strict
|
||||
```
|
||||
|
||||
## Output Model
|
||||
|
||||
The CLI prints:
|
||||
- Scan summary counts
|
||||
- `FULL_BLOCK` findings
|
||||
- `ENTRY` findings
|
||||
- `INTRA` findings
|
||||
- `CENTRAL_SCRIPT` findings
|
||||
|
||||
Exit codes:
|
||||
- `0`: success (or findings in non-strict mode)
|
||||
- `1`: findings present in strict mode
|
||||
- `2`: parse/path errors
|
||||
|
||||
## Notes
|
||||
|
||||
- This verifier intentionally keeps text output and a small CLI surface.
|
||||
- It does not implement suppression files, severity scoring, JSON output, or diff-only mode.
|
||||
- It is not an orphan entity cleaner and should not delete Home Assistant registry entries during normal DRY runs.
|
||||
- Treat generic `unavailable`, disabled, or no-`config_entry_id` entities as audit signals only; YAML, helper, REST, command-line, MQTT, finance, YouTube, and local infrastructure telemetry can be intentional.
|
||||
- Use it as a fast pre-refactor signal and pair with Home Assistant config validation before restart/reload.
|
||||
|
||||
**All of my configuration files are tested against the most stable version of home-assistant.**
|
||||
|
||||
<a name="bottom" href="https://github.com/CCOSTAN/Home-AssistantConfig#logo"><img align="right" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/up_arrow.png" width="25" ></a>
|
||||
|
||||
**Still have questions on my Config?** <br>
|
||||
**Message me on X :** [](https://www.x.com/ccostan)
|
||||
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">You can buy me a coffee</span></a><a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"></a>
|
||||
<br>
|
||||
<a href="https://eepurl.com/dmXFYz"><img align="center" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/email_link.png" height="50" ></a><br>
|
||||
<a href="https://www.vcloudinfo.com/p/affiliate-disclosure.html">
|
||||
Affiliate Disclosure
|
||||
</a></p>
|
||||
@ -1,101 +0,0 @@
|
||||
---
|
||||
name: homeassistant-yaml-dry-verifier
|
||||
description: "Verify Home Assistant YAML for DRY and efficiency issues by detecting redundant trigger/condition/action/sequence structures and repeated blocks across automations, scripts, and packages. Use when creating, reviewing, or refactoring YAML in config/packages, config/automations, config/scripts, or dashboard-related YAML where duplication risk is high. Include a read-only entity/reference safety pass when automation changes rename/remove entities or introduce maintenance cleanup behavior."
|
||||
---
|
||||
|
||||
# Home Assistant YAML DRY Verifier
|
||||
|
||||
Use this skill to lint Home Assistant YAML for repeat logic before or after edits, then refactor repeated blocks into reusable helpers.
|
||||
|
||||
## Mandatory Resolution Policy
|
||||
|
||||
- If the verifier reports findings for files touched in the current task, do not stop at reporting.
|
||||
- Resolve the findings in the same task by refactoring YAML to remove duplication.
|
||||
- Re-run the verifier after refactoring and iterate until targeted findings are cleared.
|
||||
- If a finding cannot be safely resolved, explicitly document the blocker and the smallest safe follow-up.
|
||||
- If touched YAML performs cleanup, registry hygiene, purge, deletion, or other destructive maintenance, require preview/dry-run behavior, explicit confirmation, and audit/backup output before any destructive action is considered complete.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Run the verifier script on the file(s) you edited.
|
||||
2. Review repeated block findings first (highest confidence).
|
||||
3. Refactor into shared scripts/helpers/templates where appropriate.
|
||||
4. If the change renames/removes entity references or adds maintenance cleanup behavior, perform the read-only entity/reference safety pass.
|
||||
5. Re-run the verifier and then run your normal Home Assistant config check.
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages/life360.yaml --strict
|
||||
```
|
||||
|
||||
Scan a full directory when doing wider cleanup:
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/automations
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify target YAML:
|
||||
- Prefer changed files first.
|
||||
- Include adjacent package/script files when the change might duplicate existing logic.
|
||||
|
||||
2. Run verifier:
|
||||
- Use `--min-occurrences 2` (default) for normal checks.
|
||||
- Use `--strict` when you want non-zero exit if duplication is found.
|
||||
|
||||
3. Prioritize findings in this order:
|
||||
- `FULL_BLOCK`: repeated full trigger/condition/action/sequence blocks.
|
||||
- `ENTRY`: repeated individual entries inside those blocks (excluding entries already fully covered by a `FULL_BLOCK` duplicate).
|
||||
- `INTRA`: duplicate entries inside a single block.
|
||||
- `CENTRAL_SCRIPT`: script is defined in `config/packages` but called from 2+ YAML files.
|
||||
|
||||
4. Refactor with intent:
|
||||
- Repeated actions/sequence: move to a reusable `script.*`, pass variables.
|
||||
- Repeated conditions: extract to template binary sensors or helper entities.
|
||||
- Repeated triggers: consolidate where behavior is equivalent, or split by intent if readability improves.
|
||||
- For cooldown/throttle behavior, prefer automation-local `this.attributes.last_triggered` with custom event handoff before adding new helper entities, unless shared persistent state is required across automations.
|
||||
|
||||
5. Entity/Registry Safety Pass:
|
||||
- Keep this pass read-only during normal DRY work. Report possible stale references or risky cleanup behavior; do not delete Home Assistant registry entries as part of this skill.
|
||||
- When refactors rename, remove, or consolidate automations, scripts, helpers, entities, service calls, or dashboard targets, search touched and adjacent YAML for stale references before closing the task.
|
||||
- Use live Home Assistant context or registry/state evidence when available and in scope, especially before changing entity IDs or automations that depend on device/integration state.
|
||||
- Do not treat generic `unavailable`, disabled, or no-`config_entry_id` entities as safe deletion candidates. In YAML-heavy setups these are often intentional.
|
||||
- Treat these platforms as common false-positive sources unless stronger evidence proves otherwise: `automation`, `script`, `scene`, `template`, helpers (`input_*`, `group`, `timer`, `counter`, `schedule`, `zone`, `person`, `tag`), `command_line`, `rest`, `mqtt`, `yahoofinance`, `youtube`, and local infrastructure telemetry.
|
||||
- For cleanup or maintenance automations, prefer a preview/report action first, persistent ignore rules where repeated noise is expected, and an audit artifact that records what would change or did change.
|
||||
- Destructive cleanup must be gated by explicit operator confirmation and should have backup/audit output. A dry-run-only recommendation is acceptable when the evidence is not strong enough.
|
||||
|
||||
6. Validate after edits:
|
||||
- Re-run this verifier.
|
||||
- Run Home Assistant config validation before reload/restart.
|
||||
|
||||
7. Enforce closure:
|
||||
- Treat unresolved `FULL_BLOCK`/`ENTRY` findings in touched files as incomplete work unless a blocker is documented.
|
||||
- Prefer consolidating duplicated automation triggers/conditions/actions into shared logic or a single branching automation.
|
||||
- Treat unresolved `CENTRAL_SCRIPT` findings in touched scope as incomplete unless documented as deferred-with-blocker.
|
||||
- Move shared package scripts to `config/script/<script_id>.yaml` when they are used cross-file.
|
||||
- Treat unresolved stale-reference or cleanup-safety concerns in touched scope as incomplete unless documented as `deferred-with-blocker`.
|
||||
|
||||
## Dashboard Designer Integration
|
||||
|
||||
When dashboard or automation work includes YAML edits beyond card layout, use this verifier after generation to catch duplicated logic that may have been introduced during fast refactors.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Always report:
|
||||
- Total files scanned.
|
||||
- Parse errors (if any).
|
||||
- Duplicate groups by kind (`trigger`, `condition`, `action`, `sequence`).
|
||||
- Central script placement findings (`CENTRAL_SCRIPT`) with definition + caller files.
|
||||
- Script caller detection should include direct `service: script.<id>` and `script.turn_on`-style entity targeting when present.
|
||||
- Concrete refactor recommendation per group.
|
||||
- Resolution status for each finding (`resolved`, `deferred-with-blocker`).
|
||||
- Entity/reference hygiene status when this pass is in scope (`checked`, `not-in-scope`, or `deferred-with-blocker`).
|
||||
- For cleanup or destructive maintenance YAML, preview/dry-run, confirmation, and audit/backup status.
|
||||
|
||||
Strict behavior:
|
||||
- `--strict` returns non-zero for any reported finding (`FULL_BLOCK`, `ENTRY`, `INTRA`, `CENTRAL_SCRIPT`).
|
||||
- Without `--strict`, findings are reported but exit remains zero unless parse errors occur.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/refactor_playbook.md` for concise DRY refactor patterns.
|
||||
@ -1,13 +0,0 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# YAML DRY Verifier Agent - Codex skill entry metadata.
|
||||
# Defines the OpenAI-facing launcher text for this local skill.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
interface:
|
||||
display_name: "HA YAML DRY Verifier"
|
||||
short_description: "Find redundant HA YAML logic"
|
||||
default_prompt: "Use $homeassistant-yaml-dry-verifier to verify Home Assistant YAML for DRY violations, redundant triggers/actions/conditions, and refactor opportunities."
|
||||
@ -1,33 +0,0 @@
|
||||
# Home Assistant YAML DRY Refactor Playbook
|
||||
|
||||
Use these patterns after verifier findings.
|
||||
|
||||
## Repeated Actions or Sequence
|
||||
|
||||
- Move common action chains into a reusable script (`script.<name>`).
|
||||
- Use script fields/variables instead of duplicating payload variants.
|
||||
- Keep each automation focused on trigger + routing, not full action implementation.
|
||||
|
||||
## Repeated Conditions
|
||||
|
||||
- Promote shared logic into helper entities (`input_boolean`, helper groups) or template sensors.
|
||||
- Replace long repeated `and` chains with a single meaningful condition entity when practical.
|
||||
- Keep per-automation overrides small and explicit.
|
||||
|
||||
## Repeated Triggers
|
||||
|
||||
- Merge equivalent triggers into one automation when the resulting behavior stays clear.
|
||||
- If actions diverge, keep separate automations but centralize shared actions in scripts.
|
||||
- Avoid copy-paste time/state triggers that only differ by one minor field; parameterize if possible.
|
||||
|
||||
## Intra-Block Duplicates
|
||||
|
||||
- Remove exact duplicate entries inside the same trigger/condition/action list.
|
||||
- Keep only one canonical copy of each entry.
|
||||
|
||||
## Validation Loop
|
||||
|
||||
1. Run verifier.
|
||||
2. Refactor.
|
||||
3. Run verifier again.
|
||||
4. Run Home Assistant config check before reload/restart.
|
||||
@ -1,565 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Detect DRY violations in Home Assistant YAML by finding repeated structures.
|
||||
|
||||
Focus areas:
|
||||
- Repeated trigger/condition/action blocks across automations
|
||||
- Repeated sequence blocks across scripts
|
||||
- Repeated entries inside those blocks
|
||||
- Duplicate entries within a single block
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class _Tagged(str):
|
||||
"""Opaque holder for unknown YAML tags such as !include or !secret."""
|
||||
|
||||
|
||||
class _Loader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def _construct_undefined(loader: _Loader, node: yaml.Node) -> Any:
|
||||
if isinstance(node, yaml.ScalarNode):
|
||||
return _Tagged(f"{node.tag} {node.value}")
|
||||
if isinstance(node, yaml.SequenceNode):
|
||||
seq = loader.construct_sequence(node)
|
||||
return _Tagged(f"{node.tag} {seq!r}")
|
||||
if isinstance(node, yaml.MappingNode):
|
||||
mapping = loader.construct_mapping(node)
|
||||
return _Tagged(f"{node.tag} {mapping!r}")
|
||||
return _Tagged(f"{node.tag}")
|
||||
|
||||
|
||||
_Loader.add_constructor(None, _construct_undefined) # type: ignore[arg-type]
|
||||
|
||||
|
||||
AUTOMATION_KEYS: dict[str, tuple[str, ...]] = {
|
||||
"trigger": ("trigger", "triggers"),
|
||||
"condition": ("condition", "conditions"),
|
||||
"action": ("action", "actions"),
|
||||
}
|
||||
|
||||
SCRIPT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
"sequence": ("sequence",),
|
||||
}
|
||||
|
||||
SKIP_DIRS = {".git", ".venv", ".codex_tmp", "__pycache__", ".mypy_cache"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
kind: str # "automation" | "script"
|
||||
name: str
|
||||
file_path: str
|
||||
path: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
file_path: str
|
||||
candidate_name: str
|
||||
candidate_path: str
|
||||
block_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParseError:
|
||||
file_path: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CentralScriptFinding:
|
||||
script_id: str
|
||||
definition_files: tuple[str, ...]
|
||||
caller_files: tuple[str, ...]
|
||||
|
||||
|
||||
def _discover_yaml_files(paths: Iterable[str]) -> list[Path]:
|
||||
found: set[Path] = set()
|
||||
for raw in paths:
|
||||
p = Path(raw)
|
||||
if not p.exists():
|
||||
continue
|
||||
if p.is_file() and p.suffix.lower() in {".yaml", ".yml"}:
|
||||
found.add(p.resolve())
|
||||
continue
|
||||
if p.is_dir():
|
||||
for root, dirs, files in os.walk(p):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
root_path = Path(root)
|
||||
for name in files:
|
||||
if Path(name).suffix.lower() in {".yaml", ".yml"}:
|
||||
found.add((root_path / name).resolve())
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _load_yaml_docs(path: Path) -> list[Any]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
return list(yaml.load_all(text, Loader=_Loader))
|
||||
|
||||
|
||||
def _looks_like_automation(v: Any) -> bool:
|
||||
if not isinstance(v, dict):
|
||||
return False
|
||||
has_trigger = "trigger" in v or "triggers" in v
|
||||
has_action = "action" in v or "actions" in v
|
||||
return has_trigger and has_action
|
||||
|
||||
|
||||
def _looks_like_script(v: Any) -> bool:
|
||||
return isinstance(v, dict) and "sequence" in v
|
||||
|
||||
|
||||
def _candidate_name(kind: str, v: dict[str, Any], fallback: str) -> str:
|
||||
alias = v.get("alias")
|
||||
if isinstance(alias, str) and alias.strip():
|
||||
return alias.strip()
|
||||
cid = v.get("id")
|
||||
if isinstance(cid, str) and cid.strip():
|
||||
return cid.strip()
|
||||
return f"{kind}:{fallback}"
|
||||
|
||||
|
||||
def _iter_container_items(container: Any) -> Iterable[tuple[str, dict[str, Any]]]:
|
||||
if isinstance(container, list):
|
||||
for idx, item in enumerate(container):
|
||||
if isinstance(item, dict):
|
||||
yield f"[{idx}]", item
|
||||
return
|
||||
if isinstance(container, dict):
|
||||
for key, item in container.items():
|
||||
if isinstance(item, dict):
|
||||
yield f".{key}", item
|
||||
|
||||
|
||||
def _extract_candidates_from_doc(doc: Any, file_path: str, doc_idx: int) -> list[Candidate]:
|
||||
out: list[Candidate] = []
|
||||
root_path = "$" if doc_idx == 0 else f"$doc[{doc_idx}]"
|
||||
|
||||
if isinstance(doc, list):
|
||||
for suffix, item in _iter_container_items(doc):
|
||||
if _looks_like_automation(item):
|
||||
name = _candidate_name("automation", item, f"{root_path}{suffix}")
|
||||
out.append(
|
||||
Candidate(
|
||||
kind="automation",
|
||||
name=name,
|
||||
file_path=file_path,
|
||||
path=f"{root_path}{suffix}",
|
||||
data=item,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
return out
|
||||
|
||||
if _looks_like_automation(doc):
|
||||
name = _candidate_name("automation", doc, root_path)
|
||||
out.append(Candidate("automation", name, file_path, root_path, doc))
|
||||
|
||||
if _looks_like_script(doc):
|
||||
name = _candidate_name("script", doc, root_path)
|
||||
out.append(Candidate("script", name, file_path, root_path, doc))
|
||||
|
||||
if "automation" in doc:
|
||||
for suffix, item in _iter_container_items(doc["automation"]):
|
||||
if _looks_like_automation(item):
|
||||
name = _candidate_name("automation", item, f"{root_path}.automation{suffix}")
|
||||
out.append(
|
||||
Candidate(
|
||||
kind="automation",
|
||||
name=name,
|
||||
file_path=file_path,
|
||||
path=f"{root_path}.automation{suffix}",
|
||||
data=item,
|
||||
)
|
||||
)
|
||||
|
||||
if "script" in doc:
|
||||
for suffix, item in _iter_container_items(doc["script"]):
|
||||
if _looks_like_script(item):
|
||||
name = _candidate_name("script", item, f"{root_path}.script{suffix}")
|
||||
out.append(
|
||||
Candidate(
|
||||
kind="script",
|
||||
name=name,
|
||||
file_path=file_path,
|
||||
path=f"{root_path}.script{suffix}",
|
||||
data=item,
|
||||
)
|
||||
)
|
||||
|
||||
if out:
|
||||
return out
|
||||
|
||||
for key, item in doc.items():
|
||||
if isinstance(item, dict) and _looks_like_automation(item):
|
||||
name = _candidate_name("automation", item, f"{root_path}.{key}")
|
||||
out.append(Candidate("automation", name, file_path, f"{root_path}.{key}", item))
|
||||
if isinstance(item, dict) and _looks_like_script(item):
|
||||
name = _candidate_name("script", item, f"{root_path}.{key}")
|
||||
out.append(Candidate("script", name, file_path, f"{root_path}.{key}", item))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _normalize(value: Any) -> Any:
|
||||
if isinstance(value, _Tagged):
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
normalized_items: list[tuple[str, Any]] = []
|
||||
for k, v in value.items():
|
||||
normalized_items.append((str(k), _normalize(v)))
|
||||
normalized_items.sort(key=lambda i: i[0])
|
||||
return {k: v for k, v in normalized_items}
|
||||
if isinstance(value, list):
|
||||
return [_normalize(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _fingerprint(value: Any) -> str:
|
||||
return json.dumps(_normalize(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def _first_present_key(mapping: dict[str, Any], aliases: tuple[str, ...]) -> str | None:
|
||||
for key in aliases:
|
||||
if key in mapping:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _iter_entries(value: Any) -> Iterable[tuple[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
for idx, entry in enumerate(value):
|
||||
yield f"[{idx}]", entry
|
||||
return
|
||||
yield "", value
|
||||
|
||||
|
||||
def _block_keys_for_candidate(candidate: Candidate) -> dict[str, tuple[str, ...]]:
|
||||
if candidate.kind == "automation":
|
||||
return AUTOMATION_KEYS
|
||||
return SCRIPT_KEYS
|
||||
|
||||
|
||||
def _recommendation(block_label: str) -> str:
|
||||
if block_label in {"action", "sequence"}:
|
||||
return (
|
||||
"Move repeated logic to config/script/<script_id>.yaml and call it "
|
||||
"via service: script.<script_id> with variables."
|
||||
)
|
||||
if block_label == "condition":
|
||||
return (
|
||||
"Extract shared condition logic into helper/template entities or "
|
||||
"merge condition blocks when behavior is equivalent."
|
||||
)
|
||||
return (
|
||||
"Consolidate equivalent trigger patterns and keep shared actions in a "
|
||||
"single reusable script when possible."
|
||||
)
|
||||
|
||||
|
||||
def _render_occurrences(occurrences: list[Occurrence], max_rows: int = 6) -> str:
|
||||
lines: list[str] = []
|
||||
for occ in occurrences[:max_rows]:
|
||||
lines.append(
|
||||
f" - {occ.file_path} :: {occ.block_path} ({occ.candidate_name})"
|
||||
)
|
||||
if len(occurrences) > max_rows:
|
||||
lines.append(f" - ... {len(occurrences) - max_rows} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return path.replace("\\", "/").lower()
|
||||
|
||||
|
||||
def _entry_parent_block_path(block_path: str) -> str:
|
||||
"""Return parent block path for entry occurrences (strip trailing [idx])."""
|
||||
return re.sub(r"\[\d+\]$", "", block_path)
|
||||
|
||||
|
||||
def _occurrence_key(
|
||||
occurrence: Occurrence, *, treat_as_entry: bool = False
|
||||
) -> tuple[str, str, str]:
|
||||
block_path = (
|
||||
_entry_parent_block_path(occurrence.block_path)
|
||||
if treat_as_entry
|
||||
else occurrence.block_path
|
||||
)
|
||||
return (occurrence.file_path, occurrence.candidate_path, block_path)
|
||||
|
||||
|
||||
def _infer_script_id(candidate: Candidate) -> str | None:
|
||||
if candidate.kind != "script":
|
||||
return None
|
||||
marker = ".script."
|
||||
if marker in candidate.path:
|
||||
return candidate.path.split(marker, 1)[1]
|
||||
if "/config/script/" in _normalize_path(candidate.file_path):
|
||||
if candidate.path.startswith("$."):
|
||||
return candidate.path[2:]
|
||||
match = re.match(r"^\$doc\[\d+\]\.(.+)$", candidate.path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_script_service_calls(node: Any, script_ids: set[str]) -> None:
|
||||
"""Collect called script IDs from common HA service invocation patterns."""
|
||||
script_domain_meta_services = {"turn_on", "toggle", "reload", "stop"}
|
||||
|
||||
def _add_script_entity_ids(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
if value.startswith("script."):
|
||||
entity_script_id = value.split(".", 1)[1].strip()
|
||||
if entity_script_id:
|
||||
script_ids.add(entity_script_id)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_add_script_entity_ids(item)
|
||||
|
||||
if isinstance(node, dict):
|
||||
service_name_raw = node.get("service")
|
||||
action_name_raw = node.get("action")
|
||||
service_name = None
|
||||
if isinstance(service_name_raw, str):
|
||||
service_name = service_name_raw.strip()
|
||||
elif isinstance(action_name_raw, str):
|
||||
service_name = action_name_raw.strip()
|
||||
|
||||
if service_name and service_name.startswith("script."):
|
||||
tail = service_name.split(".", 1)[1].strip()
|
||||
if tail and tail not in script_domain_meta_services:
|
||||
script_ids.add(tail)
|
||||
else:
|
||||
_add_script_entity_ids(node.get("entity_id"))
|
||||
for key in ("target", "data", "service_data"):
|
||||
container = node.get(key)
|
||||
if isinstance(container, dict):
|
||||
_add_script_entity_ids(container.get("entity_id"))
|
||||
|
||||
for value in node.values():
|
||||
_collect_script_service_calls(value, script_ids)
|
||||
return
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
_collect_script_service_calls(item, script_ids)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(description="Detect duplicated Home Assistant YAML structures.")
|
||||
ap.add_argument("paths", nargs="+", help="YAML file(s) or directory path(s) to scan")
|
||||
ap.add_argument("--min-occurrences", type=int, default=2, help="Minimum duplicate count to report (default: 2)")
|
||||
ap.add_argument("--max-groups", type=int, default=50, help="Maximum duplicate groups to print (default: 50)")
|
||||
ap.add_argument("--strict", action="store_true", help="Return non-zero when duplicates are found")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.min_occurrences < 2:
|
||||
print("ERROR: --min-occurrences must be >= 2", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = _discover_yaml_files(args.paths)
|
||||
if not files:
|
||||
print("ERROR: no YAML files found for the provided paths", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
parse_errors: list[ParseError] = []
|
||||
candidates: list[Candidate] = []
|
||||
script_calls_by_id: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for path in files:
|
||||
try:
|
||||
docs = _load_yaml_docs(path)
|
||||
except Exception as exc:
|
||||
parse_errors.append(ParseError(file_path=str(path), error=str(exc)))
|
||||
continue
|
||||
|
||||
script_calls_in_file: set[str] = set()
|
||||
for doc_idx, doc in enumerate(docs):
|
||||
candidates.extend(_extract_candidates_from_doc(doc, str(path), doc_idx))
|
||||
_collect_script_service_calls(doc, script_calls_in_file)
|
||||
for script_id in script_calls_in_file:
|
||||
script_calls_by_id[script_id].add(str(path))
|
||||
|
||||
full_index: dict[tuple[str, str, str], list[Occurrence]] = defaultdict(list)
|
||||
entry_index: dict[tuple[str, str, str], list[Occurrence]] = defaultdict(list)
|
||||
intra_duplicate_notes: list[str] = []
|
||||
|
||||
for candidate in candidates:
|
||||
block_key_map = _block_keys_for_candidate(candidate)
|
||||
for block_label, aliases in block_key_map.items():
|
||||
source_key = _first_present_key(candidate.data, aliases)
|
||||
if not source_key:
|
||||
continue
|
||||
|
||||
block_value = candidate.data[source_key]
|
||||
if block_value in (None, [], {}):
|
||||
continue
|
||||
|
||||
block_fp = _fingerprint(block_value)
|
||||
full_index[(candidate.kind, block_label, block_fp)].append(
|
||||
Occurrence(
|
||||
file_path=candidate.file_path,
|
||||
candidate_name=candidate.name,
|
||||
candidate_path=candidate.path,
|
||||
block_path=f"{candidate.path}.{source_key}",
|
||||
)
|
||||
)
|
||||
|
||||
seen_in_candidate: dict[str, list[str]] = defaultdict(list)
|
||||
for suffix, entry in _iter_entries(block_value):
|
||||
entry_fp = _fingerprint(entry)
|
||||
entry_occ = Occurrence(
|
||||
file_path=candidate.file_path,
|
||||
candidate_name=candidate.name,
|
||||
candidate_path=candidate.path,
|
||||
block_path=f"{candidate.path}.{source_key}{suffix}",
|
||||
)
|
||||
entry_index[(candidate.kind, block_label, entry_fp)].append(entry_occ)
|
||||
seen_in_candidate[entry_fp].append(entry_occ.block_path)
|
||||
|
||||
for entry_fp, block_paths in seen_in_candidate.items():
|
||||
if len(block_paths) >= args.min_occurrences:
|
||||
intra_duplicate_notes.append(
|
||||
(
|
||||
f"INTRA {candidate.kind}.{block_label}: {candidate.name} has "
|
||||
f"{len(block_paths)} duplicated entries in {candidate.path}.{source_key}"
|
||||
)
|
||||
)
|
||||
|
||||
def _filter_groups(index: dict[tuple[str, str, str], list[Occurrence]]) -> list[tuple[tuple[str, str, str], list[Occurrence]]]:
|
||||
groups = [(k, v) for k, v in index.items() if len(v) >= args.min_occurrences]
|
||||
groups.sort(key=lambda item: (-len(item[1]), item[0][0], item[0][1]))
|
||||
return groups
|
||||
|
||||
full_groups = _filter_groups(full_index)
|
||||
entry_groups = _filter_groups(entry_index)
|
||||
|
||||
# Drop ENTRY groups that are fully subsumed by an identical FULL_BLOCK group.
|
||||
full_group_member_sets: dict[tuple[str, str], list[set[tuple[str, str, str]]]] = defaultdict(list)
|
||||
for (kind, block_label, _), occurrences in full_groups:
|
||||
full_group_member_sets[(kind, block_label)].append(
|
||||
{_occurrence_key(occ) for occ in occurrences}
|
||||
)
|
||||
|
||||
filtered_entry_groups: list[tuple[tuple[str, str, str], list[Occurrence]]] = []
|
||||
for entry_group_key, entry_occurrences in entry_groups:
|
||||
kind, block_label, _ = entry_group_key
|
||||
entry_member_set = {
|
||||
_occurrence_key(occ, treat_as_entry=True) for occ in entry_occurrences
|
||||
}
|
||||
full_sets = full_group_member_sets.get((kind, block_label), [])
|
||||
is_subsumed = any(entry_member_set.issubset(full_set) for full_set in full_sets)
|
||||
if not is_subsumed:
|
||||
filtered_entry_groups.append((entry_group_key, entry_occurrences))
|
||||
|
||||
entry_groups = filtered_entry_groups
|
||||
intra_duplicate_notes = sorted(set(intra_duplicate_notes))
|
||||
script_definitions_by_id: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for candidate in candidates:
|
||||
script_id = _infer_script_id(candidate)
|
||||
if script_id:
|
||||
script_definitions_by_id[script_id].add(candidate.file_path)
|
||||
|
||||
central_script_findings: list[CentralScriptFinding] = []
|
||||
for script_id, definition_files in script_definitions_by_id.items():
|
||||
normalized_definitions = {_normalize_path(path): path for path in definition_files}
|
||||
if not any("/config/packages/" in n for n in normalized_definitions):
|
||||
continue
|
||||
if any("/config/script/" in n for n in normalized_definitions):
|
||||
continue
|
||||
caller_files = sorted(script_calls_by_id.get(script_id, set()))
|
||||
if len(caller_files) < 2:
|
||||
continue
|
||||
central_script_findings.append(
|
||||
CentralScriptFinding(
|
||||
script_id=script_id,
|
||||
definition_files=tuple(sorted(definition_files)),
|
||||
caller_files=tuple(caller_files),
|
||||
)
|
||||
)
|
||||
|
||||
central_script_findings.sort(key=lambda item: (-len(item.caller_files), item.script_id))
|
||||
|
||||
print(f"Scanned files: {len(files)}")
|
||||
print(f"Parsed candidates: {len(candidates)}")
|
||||
print(f"Parse errors: {len(parse_errors)}")
|
||||
print(f"Duplicate full-block groups: {len(full_groups)}")
|
||||
print(f"Duplicate entry groups: {len(entry_groups)}")
|
||||
print(f"Intra-block duplicates: {len(intra_duplicate_notes)}")
|
||||
print(f"Central-script findings: {len(central_script_findings)}")
|
||||
|
||||
if parse_errors:
|
||||
print("\nParse errors:")
|
||||
for err in parse_errors:
|
||||
print(f" - {err.file_path}: {err.error}")
|
||||
|
||||
if full_groups:
|
||||
print("\nFULL_BLOCK findings:")
|
||||
for idx, ((kind, block_label, _), occurrences) in enumerate(full_groups[: args.max_groups], start=1):
|
||||
print(f"{idx}. {kind}.{block_label} repeated {len(occurrences)} times")
|
||||
print(_render_occurrences(occurrences))
|
||||
print(f" suggestion: {_recommendation(block_label)}")
|
||||
|
||||
if entry_groups:
|
||||
print("\nENTRY findings:")
|
||||
for idx, ((kind, block_label, _), occurrences) in enumerate(entry_groups[: args.max_groups], start=1):
|
||||
print(f"{idx}. {kind}.{block_label} entry repeated {len(occurrences)} times")
|
||||
print(_render_occurrences(occurrences))
|
||||
print(f" suggestion: {_recommendation(block_label)}")
|
||||
|
||||
if intra_duplicate_notes:
|
||||
print("\nINTRA findings:")
|
||||
for idx, note in enumerate(intra_duplicate_notes[: args.max_groups], start=1):
|
||||
print(f"{idx}. {note}")
|
||||
|
||||
if central_script_findings:
|
||||
print("\nCENTRAL_SCRIPT findings:")
|
||||
for idx, finding in enumerate(central_script_findings[: args.max_groups], start=1):
|
||||
print(
|
||||
f"{idx}. script.{finding.script_id} is package-defined and called from "
|
||||
f"{len(finding.caller_files)} files"
|
||||
)
|
||||
for definition_file in finding.definition_files:
|
||||
print(f" - definition: {definition_file}")
|
||||
for caller_file in finding.caller_files[:6]:
|
||||
print(f" - caller: {caller_file}")
|
||||
if len(finding.caller_files) > 6:
|
||||
print(f" - ... {len(finding.caller_files) - 6} more callers")
|
||||
print(f" suggestion: Move definition to config/script/{finding.script_id}.yaml")
|
||||
|
||||
finding_count = (
|
||||
len(full_groups)
|
||||
+ len(entry_groups)
|
||||
+ len(intra_duplicate_notes)
|
||||
+ len(central_script_findings)
|
||||
)
|
||||
if args.strict and finding_count > 0:
|
||||
return 1
|
||||
if parse_errors:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1,302 +0,0 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "scripts" / "verify_ha_yaml_dry.py"
|
||||
)
|
||||
MODULE_NAME = "verify_ha_yaml_dry"
|
||||
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[MODULE_NAME] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class VerifyHaYamlDryTests(unittest.TestCase):
|
||||
def _run_verifier(
|
||||
self,
|
||||
files: dict[str, str],
|
||||
*extra_args: str,
|
||||
scan_subpath: str = ".",
|
||||
) -> tuple[int, str, str]:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
for rel_path, content in files.items():
|
||||
file_path = root / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
scan_path = root / scan_subpath
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
rc = MODULE.main([str(scan_path), *extra_args])
|
||||
return rc, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _full_block_group_order(output: str) -> list[str]:
|
||||
marker = "\nFULL_BLOCK findings:\n"
|
||||
if marker not in output:
|
||||
return []
|
||||
section = output.split(marker, 1)[1]
|
||||
for stop in ("\nENTRY findings:\n", "\nINTRA findings:\n", "\nCENTRAL_SCRIPT findings:\n"):
|
||||
if stop in section:
|
||||
section = section.split(stop, 1)[0]
|
||||
break
|
||||
|
||||
groups: list[str] = []
|
||||
for line in section.splitlines():
|
||||
text = line.strip()
|
||||
match = re.match(r"^\d+\.\s+([a-z]+\.[a-z_]+)\s+repeated\s+\d+\s+times$", text)
|
||||
if match:
|
||||
groups.append(match.group(1))
|
||||
return groups
|
||||
|
||||
def test_full_block_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate full-block groups: 1", out)
|
||||
self.assertIn("FULL_BLOCK findings:", out)
|
||||
self.assertIn("automation.action repeated 2 times", out)
|
||||
|
||||
def test_entry_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.shared_handler
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.shared_handler
|
||||
- delay: "00:00:01"
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate entry groups: 1", out)
|
||||
self.assertIn("ENTRY findings:", out)
|
||||
self.assertIn("automation.action entry repeated 2 times", out)
|
||||
|
||||
def test_intra_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: Repeat Inside Block
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.den
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.den
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("INTRA findings:", out)
|
||||
self.assertIn("INTRA automation.action: Repeat Inside Block has 2 duplicated entries", out)
|
||||
|
||||
def test_central_script_detection_with_package_definition_and_multi_caller(self) -> None:
|
||||
files = {
|
||||
"config/packages/shared_scripts.yaml": """
|
||||
script:
|
||||
my_shared_script:
|
||||
alias: Shared Script
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: Shared
|
||||
message: Hello
|
||||
""",
|
||||
"config/automations/caller_one.yaml": """
|
||||
automation:
|
||||
- alias: Caller One
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.my_shared_script
|
||||
""",
|
||||
"config/automations/caller_two.yaml": """
|
||||
automation:
|
||||
- alias: Caller Two
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.turn_on
|
||||
target:
|
||||
entity_id: script.my_shared_script
|
||||
""",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Central-script findings: 1", out)
|
||||
self.assertIn("script.my_shared_script is package-defined and called from 2 files", out)
|
||||
self.assertIn("suggestion: Move definition to config/script/my_shared_script.yaml", out)
|
||||
|
||||
def test_subsumed_entry_groups_are_collapsed(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate full-block groups: 1", out)
|
||||
self.assertIn("Duplicate entry groups: 0", out)
|
||||
|
||||
def test_full_block_findings_order_is_deterministic(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.same
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.same
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
"scripts.yaml": """
|
||||
script:
|
||||
script_one:
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
script_two:
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
order = self._full_block_group_order(out)
|
||||
self.assertGreaterEqual(len(order), 3, out)
|
||||
self.assertEqual(order[:3], ["automation.action", "automation.trigger", "script.sequence"])
|
||||
|
||||
def test_exit_codes_for_strict_and_non_strict_modes(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc_non_strict, _, _ = self._run_verifier(files)
|
||||
rc_strict, _, _ = self._run_verifier(files, "--strict")
|
||||
self.assertEqual(rc_non_strict, 0)
|
||||
self.assertEqual(rc_strict, 1)
|
||||
|
||||
def test_parse_error_path_returns_exit_code_two(self) -> None:
|
||||
files = {
|
||||
"good.yaml": """
|
||||
automation:
|
||||
- alias: Good
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
"bad.yaml": "automation: [\n",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 2, out + err)
|
||||
self.assertIn("Parse errors:", out)
|
||||
self.assertIn("bad.yaml", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,85 +0,0 @@
|
||||
---
|
||||
name: infrastructure-doc-sync
|
||||
description: "Use when infra/container placement changes require synchronized AGENTS, docs, Dashy, and BearClaw infrastructure snapshot updates while keeping AGENTS concise, scoped, and non-runbook."
|
||||
---
|
||||
|
||||
# Infrastructure Doc Sync
|
||||
|
||||
Keep infrastructure documentation aligned after operational changes.
|
||||
Keep `AGENTS.md` short and task-scoped; move long runbooks to dedicated docs.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Docker service moved between hosts.
|
||||
- Network mode/port mapping changed.
|
||||
- Host role changed (core, camera, edge, backup, etc.).
|
||||
- Any update to an `AGENTS.md` file that affects infra context.
|
||||
- Any user-facing shortcut URL changed (Dashy).
|
||||
|
||||
## Required Update Targets (As Applicable)
|
||||
|
||||
1. Workspace infra truth:
|
||||
- `../AGENTS.md` (workspace-level host/topology source)
|
||||
2. Repo-scoped `AGENTS.md` files touched by the change:
|
||||
- Keep only hard constraints and applicability gates.
|
||||
- Remove/move long runbooks into dedicated docs.
|
||||
3. Relevant repo docs:
|
||||
- `README.md`
|
||||
- `codex_skills/README.md` (if adding/updating skills)
|
||||
4. Ops/runbook doc (if AGENTS runbook content is reduced):
|
||||
- Example: `docs/agent_ops_baselines.md` (repo-local operational baseline)
|
||||
5. Dashy shortcuts (if any service URL/host changed):
|
||||
- `h:\hass\docker_files\dashy/conf.yml`
|
||||
- Reload Dashy on docker_17 after edits: `ssh hass@192.168.10.17 "cd ~/docker_files && docker compose up -d dashy"`
|
||||
6. BearClaw infrastructure snapshot:
|
||||
- `docker_17/codex_appliance` environment map and `/api/admin/infrastructure`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect current runtime truth from hosts (containers, network mode, ports, and workload role).
|
||||
2. Update workspace `AGENTS.md` first for host/topology truth.
|
||||
3. Update repo-level `AGENTS.md` with concise, scoped constraints only.
|
||||
4. Move long operational/runbook details out of `AGENTS.md` into a dedicated doc when needed.
|
||||
5. If end-user entry points changed, update Dashy shortcuts (`dashy/conf.yml`) to match reality.
|
||||
6. Update README/skill docs impacted by the change (short, factual, no drift).
|
||||
7. Refresh/check BearClaw infrastructure context so it mirrors the same outcome at a high level.
|
||||
8. Validate:
|
||||
- JSON is valid (`python -m json.tool` equivalent).
|
||||
- Dashy `conf.yml` references the intended hostname(s)/ports (no stale LAN IPs unless intentionally required).
|
||||
- AGENTS and README statements do not conflict with runtime.
|
||||
- Repo-level AGENTS do not contain long runbooks duplicated from dedicated docs.
|
||||
- BearClaw `/api/admin/infrastructure` returns the intended topology/context.
|
||||
|
||||
## AGENTS Quality Rules
|
||||
|
||||
- Prefer short checklists over narrative paragraphs.
|
||||
- Keep only non-discoverable, high-impact constraints in AGENTS.
|
||||
- Add explicit applicability gates where a file has mixed-scope guidance.
|
||||
- Keep specialized/deeper-scoped AGENTS concise and task-specific.
|
||||
- De-duplicate repeated policy lines across global/workspace/repo scopes.
|
||||
|
||||
## BearClaw Snapshot Content Rules
|
||||
|
||||
- Keep infrastructure snapshot content high-level and planning-focused.
|
||||
- Do not include secrets, tokens, passwords, or internal file paths.
|
||||
- Avoid step-by-step runbooks.
|
||||
- Prefer host IDs and roles over low-level implementation detail.
|
||||
|
||||
## Dashy Content Rules
|
||||
|
||||
- Prefer stable hostnames (ex: `docker17`) over raw IPs when available.
|
||||
- Prefer Cloudflare/public URLs for internet-facing apps where appropriate.
|
||||
- Keep "Vibe Apps" grouped under the existing Dashy section unless the user asks for taxonomy changes.
|
||||
- After edits, reload only Dashy (avoid restarting other docker_17 services).
|
||||
|
||||
## Output Contract
|
||||
|
||||
Always report:
|
||||
|
||||
- What changed (files + purpose).
|
||||
- Final intended topology/placement.
|
||||
- Any Dashy shortcuts touched (or explicitly state "no Dashy updates needed").
|
||||
- Whether runbook content was moved from AGENTS into a dedicated ops doc.
|
||||
- BearClaw infrastructure snapshot validation result.
|
||||
- Any unresolved follow-up items.
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Infrastructure Doc Sync Agent - Codex skill entry metadata.
|
||||
# Defines the OpenAI-facing launcher text for this local skill.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
interface:
|
||||
display_name: "Infrastructure Doc Sync"
|
||||
short_description: "Sync infra docs with concise AGENTS"
|
||||
default_prompt: "Use $infrastructure-doc-sync to keep AGENTS concise and scoped, move runbook content into dedicated docs when needed, and sync README/Dashy/BearClaw infrastructure snapshot context after infra changes."
|
||||
@ -1,91 +0,0 @@
|
||||
---
|
||||
name: network-architecture-diagrammer
|
||||
description: "Create homelab and network architecture diagrams from plain-English prompts by producing Mermaid-first artifacts that import cleanly into Excalidraw. Use for Docker host topology, service dependency maps, Cloudflare/public edge diagrams, and before/after infrastructure sketches."
|
||||
---
|
||||
|
||||
# Network Architecture Diagrammer
|
||||
|
||||
Use text-first architecture diagrams that round-trip well: Mermaid source first, Excalidraw second.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user asks for a network diagram, architecture sketch, topology map, or service relationship view.
|
||||
- You need a reviewable artifact that belongs in git, docs, or an issue comment.
|
||||
- You need a fast homelab diagram from AGENTS, compose files, README notes, or runtime inventory.
|
||||
|
||||
## Primary Artifact
|
||||
|
||||
- Produce Mermaid flowcharts first.
|
||||
- Keep the output Excalidraw-importable instead of Mermaid-fancy.
|
||||
- If the user wants a file, write a `.mmd` file.
|
||||
- Add a short `.md` legend or assumptions file only when it materially helps.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect topology truth from `AGENTS.md`, compose files, repo docs, and MCP/runtime inventory when available.
|
||||
2. Pick one view:
|
||||
- context: user, cloud, edge, host
|
||||
- deployment: hosts, containers/apps, data stores
|
||||
- flow: request/data/event path
|
||||
- delta: before/after architecture change
|
||||
3. Normalize nodes into a small set:
|
||||
- users
|
||||
- internet/cloud
|
||||
- edge/tunnel/proxy
|
||||
- hosts
|
||||
- containers/apps
|
||||
- data stores
|
||||
- external systems
|
||||
4. Split large requests into two diagrams instead of forcing one dense canvas.
|
||||
5. Write Mermaid with one subgraph per host or trust zone and short, stable labels.
|
||||
6. Run `scripts/validate_mermaid_excalidraw.py` on any Mermaid file you create.
|
||||
7. Deliver the Mermaid artifact plus short assumptions and the Excalidraw import hint when useful.
|
||||
|
||||
## Excalidraw Compatibility Rules
|
||||
|
||||
- Use only Mermaid `flowchart` or `graph` syntax.
|
||||
- Prefer plain labels, simple arrows, and `subgraph`.
|
||||
- Avoid features that commonly import poorly into Excalidraw:
|
||||
- non-flowchart diagram types such as `sequenceDiagram`, `classDiagram`, `stateDiagram`, `erDiagram`, `journey`, `gantt`, `mindmap`, `timeline`, `pie`, `gitGraph`, `block-beta`, `packet-beta`, `sankey-beta`, `quadrantChart`, and `zenuml`
|
||||
- expanded shape syntax like `@{ ... }`
|
||||
- `classDef`, `class`, `style`, `linkStyle`, `click`, and HTML labels/tags
|
||||
- Prefer stable hostnames over LAN IPs unless the IPs are the point of the diagram.
|
||||
- Keep edge labels short: protocol, purpose, or port only when it changes understanding.
|
||||
- Keep each diagram roughly under 15 nodes. Split by audience or scope when needed.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Always produce:
|
||||
|
||||
1. A Mermaid code block.
|
||||
2. A short assumptions or omissions list when anything was inferred.
|
||||
3. An optional recommended filename such as `docs/diagrams/docker-host-topology.mmd`.
|
||||
4. A short note that the Mermaid can be imported into Excalidraw for cleanup when the user wants a polished visual.
|
||||
|
||||
If asked to write files, prefer:
|
||||
|
||||
- `docs/diagrams/<name>.mmd`
|
||||
- `docs/diagrams/<name>.md` for legend/notes only when helpful
|
||||
|
||||
## Prompt Pattern
|
||||
|
||||
Natural-language prompts are the default. Helpful details include:
|
||||
|
||||
- scope
|
||||
- audience
|
||||
- desired granularity
|
||||
- whether public vs LAN edge matters
|
||||
- whether a before/after comparison is needed
|
||||
|
||||
Example:
|
||||
|
||||
- "Create a deployment diagram for docker10/docker14/docker17/docker69, show Cloudflare edge publishing, and make clear which services are LAN-only."
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/excalidraw_mermaid_rules.md` for the diagram playbook and example patterns.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `python scripts/validate_mermaid_excalidraw.py <file.mmd>` when a Mermaid file is created.
|
||||
- If the validator flags unsupported syntax, simplify the diagram instead of forcing Mermaid-only features.
|
||||
@ -1,13 +0,0 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Network Diagrammer Agent - Codex skill entry metadata.
|
||||
# Defines the OpenAI-facing launcher text for this local skill.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
interface:
|
||||
display_name: "Network Architecture Diagrammer"
|
||||
short_description: "Generate Excalidraw-ready homelab diagrams"
|
||||
default_prompt: "Use $network-architecture-diagrammer to turn a plain-English homelab or network architecture request into a Mermaid-first diagram that imports cleanly into Excalidraw."
|
||||
@ -1,134 +0,0 @@
|
||||
# Excalidraw Mermaid Playbook
|
||||
|
||||
Use this reference when you need a stable, reviewable architecture diagram artifact.
|
||||
|
||||
## Goal
|
||||
|
||||
Produce Mermaid that:
|
||||
|
||||
- is easy to diff in git
|
||||
- is readable in markdown or issue comments
|
||||
- imports into Excalidraw without heavy cleanup
|
||||
|
||||
## Recommended Views
|
||||
|
||||
### Context diagram
|
||||
|
||||
Use when the audience only needs major boundaries.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[Carlo]
|
||||
Internet[Internet]
|
||||
Cloudflare[Cloudflare Tunnel]
|
||||
subgraph docker17[docker17]
|
||||
Dashy[Dashy]
|
||||
Codex[Codex Appliance]
|
||||
end
|
||||
|
||||
User --> Internet --> Cloudflare --> Dashy
|
||||
Cloudflare --> Codex
|
||||
```
|
||||
|
||||
### Deployment diagram
|
||||
|
||||
Use when the audience needs host and service placement.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph docker10[docker10]
|
||||
HA[Home Assistant]
|
||||
MQTT[MQTT]
|
||||
UniFi[UniFi]
|
||||
end
|
||||
subgraph docker14[docker14]
|
||||
Frigate[Frigate]
|
||||
end
|
||||
subgraph docker17[docker17]
|
||||
Appliance[Codex Appliance]
|
||||
Dashy[Dashy]
|
||||
end
|
||||
subgraph docker69[docker69]
|
||||
Tunnel[Cloudflared]
|
||||
PublicApps[Public Apps]
|
||||
end
|
||||
|
||||
Tunnel --> Appliance
|
||||
Tunnel --> PublicApps
|
||||
HA --> MQTT
|
||||
Frigate --> HA
|
||||
```
|
||||
|
||||
### Flow diagram
|
||||
|
||||
Use when the audience cares about one path, not the full estate.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User]
|
||||
Telegram[Telegram]
|
||||
Joanna[Joanna]
|
||||
HA[Home Assistant]
|
||||
Notify[notify_engine]
|
||||
|
||||
User --> Telegram --> Joanna --> HA --> Notify
|
||||
```
|
||||
|
||||
## Label Rules
|
||||
|
||||
- Prefer `docker17` over raw IP addresses.
|
||||
- Prefer `Cloudflare Tunnel` over product-internal nouns unless the audience already knows them.
|
||||
- Keep labels human-readable; IDs belong in notes, not in node titles.
|
||||
|
||||
## Edge Rules
|
||||
|
||||
- Use unlabeled arrows for obvious containment or traffic.
|
||||
- Add short labels only when they matter:
|
||||
- `RTSP`
|
||||
- `MQTT`
|
||||
- `HTTPS`
|
||||
- `Webhook`
|
||||
- `8124`
|
||||
|
||||
## Split Rules
|
||||
|
||||
Split into multiple diagrams when any of these are true:
|
||||
|
||||
- more than four hosts
|
||||
- more than 15 nodes
|
||||
- mixed audiences (operators vs app users)
|
||||
- both runtime placement and request flow are important
|
||||
|
||||
Good split example:
|
||||
|
||||
- diagram 1: public edge and user entry points
|
||||
- diagram 2: host/container placement
|
||||
|
||||
## Excalidraw Guardrails
|
||||
|
||||
Avoid these Mermaid features in Excalidraw-bound artifacts:
|
||||
|
||||
- non-flowchart diagram types
|
||||
- expanded shape syntax (`@{ ... }`)
|
||||
- CSS classes or class attachments
|
||||
- inline style directives
|
||||
- HTML inside labels
|
||||
- click handlers
|
||||
|
||||
If you want polish, do it after import inside Excalidraw.
|
||||
|
||||
## File Naming
|
||||
|
||||
Prefer short, obvious filenames:
|
||||
|
||||
- `docs/diagrams/bear-stone-context.mmd`
|
||||
- `docs/diagrams/docker-host-topology.mmd`
|
||||
- `docs/diagrams/cloudflare-edge-flow.mmd`
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does the diagram answer one question clearly?
|
||||
- Are host boundaries obvious?
|
||||
- Are public entry points distinguishable from LAN-only services?
|
||||
- Is the artifact still readable as raw Mermaid text?
|
||||
- Will Excalidraw import it without advanced Mermaid features?
|
||||
@ -1,77 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DISALLOWED_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
(
|
||||
re.compile(
|
||||
r"^\s*(sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|mindmap|timeline|gitGraph|quadrantChart|zenuml|block-beta|packet-beta|sankey-beta)\b",
|
||||
re.MULTILINE,
|
||||
),
|
||||
"Use Mermaid flowchart syntax only for Excalidraw import.",
|
||||
),
|
||||
(
|
||||
re.compile(r"@\{"),
|
||||
"Avoid expanded Mermaid shape syntax (`@{ ... }`); simplify the node shape.",
|
||||
),
|
||||
(
|
||||
re.compile(r"^\s*(classDef|class|style|linkStyle|click)\b", re.MULTILINE),
|
||||
"Avoid Mermaid styling/class directives; keep the diagram plain for Excalidraw.",
|
||||
),
|
||||
(
|
||||
re.compile(r":::\w+"),
|
||||
"Avoid Mermaid class attachments (`:::`); keep the diagram plain for Excalidraw.",
|
||||
),
|
||||
(
|
||||
re.compile(r"<[^>\n]+>"),
|
||||
"Avoid HTML inside Mermaid labels; use plain text labels instead.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def validate_mermaid(text: str) -> list[str]:
|
||||
findings: list[str] = []
|
||||
|
||||
if not re.search(r"^\s*(flowchart|graph)\s+(LR|RL|TB|TD|BT)\b", text, re.MULTILINE):
|
||||
findings.append("Expected a Mermaid flowchart header such as `flowchart LR` or `flowchart TD`.")
|
||||
|
||||
for pattern, message in DISALLOWED_PATTERNS:
|
||||
if pattern.search(text):
|
||||
findings.append(message)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def _read_text(path: str | None) -> str:
|
||||
if path:
|
||||
return Path(path).read_text(encoding="utf-8")
|
||||
|
||||
return sys.stdin.read()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Mermaid syntax for clean Excalidraw import."
|
||||
)
|
||||
parser.add_argument("path", nargs="?", help="Optional Mermaid file to validate")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
text = _read_text(args.path)
|
||||
findings = validate_mermaid(text)
|
||||
|
||||
if findings:
|
||||
print("Mermaid is not Excalidraw-friendly:")
|
||||
for finding in findings:
|
||||
print(f"- {finding}")
|
||||
return 1
|
||||
|
||||
print("Mermaid looks compatible with the Mermaid-to-Excalidraw workflow.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,46 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
import validate_mermaid_excalidraw as validator
|
||||
|
||||
|
||||
class ValidateMermaidExcalidrawTests(unittest.TestCase):
|
||||
def test_simple_flowchart_passes(self) -> None:
|
||||
text = """flowchart LR
|
||||
User[User] --> Tunnel[Cloudflare Tunnel] --> App[Dashy]
|
||||
"""
|
||||
self.assertEqual(validator.validate_mermaid(text), [])
|
||||
|
||||
def test_non_flowchart_diagram_fails(self) -> None:
|
||||
findings = validator.validate_mermaid(
|
||||
"""sequenceDiagram
|
||||
Alice->>Bob: hello
|
||||
"""
|
||||
)
|
||||
self.assertTrue(any("flowchart syntax only" in finding for finding in findings))
|
||||
|
||||
def test_expanded_shape_fails(self) -> None:
|
||||
findings = validator.validate_mermaid(
|
||||
"""flowchart TD
|
||||
A@{ shape: rect, label: "App" }
|
||||
"""
|
||||
)
|
||||
self.assertTrue(any("expanded Mermaid shape syntax" in finding for finding in findings))
|
||||
|
||||
def test_html_label_fails(self) -> None:
|
||||
findings = validator.validate_mermaid(
|
||||
"""flowchart TD
|
||||
A["App<br/>UI"] --> B[API]
|
||||
"""
|
||||
)
|
||||
self.assertTrue(any("Avoid HTML" in finding for finding in findings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in new issue