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