Move Codex skills to dedicated repository

master
Carlo Costanzo 1 week ago
parent 7d49a25547
commit b7319dced6

@ -27,7 +27,7 @@ Start with the automation: one Home Assistant webhook records each water softene
- You are here: `/` (root repo guide)
- [Blog](https://www.vcloudinfo.com) | [Issues](https://github.com/CCOSTAN/Home-AssistantConfig/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) | [Diagram](#network-diagram) | [YouTube](https://youtube.com/vCloudInfo)
- Config readmes: [Config index](config/README.md) | [Packages](config/packages/README.md) | [Automations](config/automation/README.md) | [Scripts](config/script/README.md) | [Scenes](config/scene/README.md) | [Sounds](config/sounds/README.md) | [Package triggers](config/packages/triggers/README.md)
- Codex skills (optional): [codex_skills](codex_skills)
- Codex skills (optional): [Home Assistant Codex Skills](https://github.com/CCOSTAN/Home-Assistant-Codex-Skills)
![Home Assistant header](https://i.imgur.com/vjDH1LJ.png)

@ -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">
[![X Follow](https://img.shields.io/static/v1?label=talk&message=3k&color=blue&logo=twitter&style=for-the-badge)](https://x.com/ccostan)
[![YouTube Subscribe](https://img.shields.io/youtube/channel/subscribers/UC301G8JJFzY0BZ_0lshpKpQ?label=VIEW&logo=Youtube&logoColor=%23DF5D44&style=for-the-badge)](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
[![GitHub Stars](https://img.shields.io/github/stars/CCOSTAN/Home-AssistantConfig.svg?label=STARS&logo=github&style=for-the-badge)](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
[![HA Version Badge](https://raw.githubusercontent.com/ccostan/home-assistantconfig/master/ha-version-badge.svg)](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
[![Last Commit](https://img.shields.io/github/last-commit/CCOSTAN/Home-AssistantConfig/master?style=plastic)](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
[![Commit Activity](https://img.shields.io/github/commit-activity/y/CCOSTAN/Home-AssistantConfig.svg?style=plastic)](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: [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/aFis2YPeSuY)
- Companion post: [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](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 :** [![Follow CCostan](https://img.shields.io/twitter/follow/CCostan)](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">
[![X Follow](https://img.shields.io/static/v1?label=talk&message=3k&color=blue&logo=twitter&style=for-the-badge)](https://x.com/ccostan)
[![YouTube Subscribe](https://img.shields.io/youtube/channel/subscribers/UC301G8JJFzY0BZ_0lshpKpQ?label=VIEW&logo=Youtube&logoColor=%23DF5D44&style=for-the-badge)](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
[![GitHub Stars](https://img.shields.io/github/stars/CCOSTAN/Home-AssistantConfig.svg?label=STARS&logo=github&style=for-the-badge)](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
[![HA Version Badge](https://raw.githubusercontent.com/ccostan/home-assistantconfig/master/ha-version-badge.svg)](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
[![Last Commit](https://img.shields.io/github/last-commit/CCOSTAN/Home-AssistantConfig/master?style=plastic)](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
[![Commit Activity](https://img.shields.io/github/commit-activity/y/CCOSTAN/Home-AssistantConfig.svg?style=plastic)](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: [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/aFis2YPeSuY)
- Companion post: [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](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 :** [![Follow CCostan](https://img.shields.io/twitter/follow/CCostan)](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">
[![X Follow](https://img.shields.io/static/v1?label=talk&message=3k&color=blue&logo=twitter&style=for-the-badge)](https://x.com/ccostan)
[![YouTube Subscribe](https://img.shields.io/youtube/channel/subscribers/UC301G8JJFzY0BZ_0lshpKpQ?label=VIEW&logo=Youtube&logoColor=%23DF5D44&style=for-the-badge)](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
[![GitHub Stars](https://img.shields.io/github/stars/CCOSTAN/Home-AssistantConfig.svg?label=STARS&logo=github&style=for-the-badge)](https://github.com/CCOSTAN/Home-AssistantConfig/stargazers) <br>
[![HA Version Badge](https://raw.githubusercontent.com/ccostan/home-assistantconfig/master/ha-version-badge.svg)](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
[![Last Commit](https://img.shields.io/github/last-commit/CCOSTAN/Home-AssistantConfig/master?style=plastic)](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
[![Commit Activity](https://img.shields.io/github/commit-activity/y/CCOSTAN/Home-AssistantConfig.svg?style=plastic)](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 :** [![Follow CCostan](https://img.shields.io/twitter/follow/CCostan)](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…
Cancel
Save

Powered by TurnKey Linux.