mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-10 07:02:29 +02:00
chore: add hassio-addon-workflow skill for Claude Code (#2939)
* chore: add hassio-addon-workflow skill for Claude Code Checks in the repo-specific skill so future Claude Code sessions get the tiered scope->measure->plan->implement->verify workflow, repo traps, and helper scripts (preflight/measure/env_trace/validate/pr_review) without depending on a local machine's ~/.claude config. * fix(skill): address PR review feedback from Copilot and Codex - SKILL.md: repo-relative script invocation (skill is now checked in); correct the CI-gates list — the PR add-on linter is blocking, only the weekly Super-Linter is non-blocking - preflight.sh: git-aware repo detection (worktrees have a .git file) - pr_review.sh: header now documents resolve's actual --all behavior - measure.sh: CPU% uses getconf CLK_TCK; sample all processes, not the top-24 by RSS - env_trace.sh: validate VAR as a strict env-var name before regex use - validate.sh: shellcheck also covers extensionless run/finish; --vs-master skips visibly when a linter is missing instead of reporting a false clean * fix(skill): address CodeRabbit review feedback - pr_review.sh: resolve exits nonzero unless every thread actually resolved; watch exits nonzero and says so when checks settle with failures instead of reporting bare "settled" - validate.sh: pass the config.yaml path to Python as argv instead of interpolating $ADDON into the source (CWE-94)
This commit is contained in:
364
.claude/skills/hassio-addon-workflow/SKILL.md
Normal file
364
.claude/skills/hassio-addon-workflow/SKILL.md
Normal file
@@ -0,0 +1,364 @@
|
||||
---
|
||||
name: hassio-addon-workflow
|
||||
description: >-
|
||||
End-to-end workflow for alexbelgium/hassio-addons add-on work — scope the change, diagnose
|
||||
against the live add-on with real measurements, get an independent Codex (gpt-5.6-sol) review
|
||||
of the plan, implement, have Codex review the code adversarially, open a PR, resolve the
|
||||
CodeRabbit / Copilot / Codex-connector review comments, and verify the merged result actually
|
||||
works. Use this whenever the task touches a Home Assistant add-on in this repo — fixing a bug
|
||||
or reported issue, tuning RAM, CPU or performance, editing a Dockerfile, config.yaml,
|
||||
cont-init.d script or s6 service, bumping an add-on version, or opening and iterating a PR
|
||||
against hassio-addons. Also use it when asked to "check with codex", "verify with chatgpt", or
|
||||
to resolve bot review comments on an add-on PR. Small tasks (typo fixes, version bumps,
|
||||
one-file tweaks, simple coding questions) route through a light path that skips measurement
|
||||
and Codex reviews — invoking this skill is cheap for small asks too.
|
||||
---
|
||||
|
||||
# Home Assistant add-on workflow
|
||||
|
||||
Triage first, then one of two paths:
|
||||
|
||||
- **Light path** (small, contained tasks): scope → implement the simplest mechanism →
|
||||
validate → PR → resolve bot comments → report honestly.
|
||||
- **Full loop** (diagnosis, performance, defaults, new mechanisms): scope → measure → plan →
|
||||
Codex attacks the plan → implement → simplify → Codex attacks the code → PR → resolve bot
|
||||
comments → verify in production → report with calibrated confidence.
|
||||
|
||||
**The standing rule on both paths:** when a simple solution and a more efficient-but-more-complex
|
||||
one both work, ship the simple one. Complexity is only paid for by a **measurement** showing the
|
||||
simple version has a concrete, user-visible cost on a real host — never by reasoning about
|
||||
hypothetical performance. Slightly less efficient and obviously correct beats faster and harder
|
||||
to review.
|
||||
|
||||
The disciplines below exist because each one, when skipped, produced a specific failure in this
|
||||
repo — the examples throughout are real, not illustrative.
|
||||
|
||||
| Discipline | The failure it prevents |
|
||||
|---|---|
|
||||
| **Triage before you start** | Full ceremony spent on a one-line fix |
|
||||
| **Scope before you work** | Solving the wrong problem, elegantly |
|
||||
| **Evidence before reasoning** | Confident claims that measurement contradicts |
|
||||
| **Reason adversarially** | Shipping something that only works on your host |
|
||||
| **Simplify to the smallest thing that works** | 500 lines of machinery where an option would do |
|
||||
| **Verify before declaring done** | "This should work" — and it doesn't |
|
||||
| **Calibrate and report** | Verified and assumed presented as the same thing |
|
||||
|
||||
**Where things are.** The repo is `alexbelgium/hassio-addons`; each add-on is a top-level
|
||||
directory (`claude_desktop/`, `birdnet-go/`, …). This skill and its scripts are checked into
|
||||
the repo at `.claude/skills/hassio-addon-workflow/` — invoke scripts from the repo root, e.g.
|
||||
`bash "$(git rev-parse --show-toplevel)/.claude/skills/hassio-addon-workflow/scripts/preflight.sh"`.
|
||||
(In the claude_desktop add-on environment the checkout lives at `/data/claude/hassio-addons`;
|
||||
a copy of this skill may also exist under `~/.claude/skills/` — the checked-in copy is
|
||||
canonical.)
|
||||
|
||||
**Three facts to know before you touch anything**, because each is silent when violated:
|
||||
|
||||
- **You cannot test the Docker build** — dockerd does not start here. CI is the only gate.
|
||||
- **Never `git stash` under `/data/claude`** — `refs/stash` is shared across worktrees and
|
||||
sessions, so it is not isolated even in your own worktree.
|
||||
- **Work in a worktree under `/data`, not `/tmp`** (`/tmp` is `noexec`).
|
||||
|
||||
Repo-specific traps live in `references/traps.md`. Read it before implementing — it is the
|
||||
accumulated cost of previous sessions. The repo's own `CLAUDE.md` covers structure, Dockerfile
|
||||
conventions, CI and lint rules.
|
||||
|
||||
---
|
||||
|
||||
## 0. Triage — pick the path
|
||||
|
||||
Classify the task before doing anything else.
|
||||
|
||||
**Light path** — typo/doc fixes, CHANGELOG edits, version bumps, one-file small edits at
|
||||
mechanism levels 1–3 (the ladder in step 3), simple coding questions. Flow: one-sentence scope →
|
||||
pick the simplest mechanism → implement → `scripts/validate.sh <addon> --vs-master` → PR
|
||||
(version bump + CHANGELOG still required) → resolve bot comments. Skip measurement, both Codex
|
||||
reviews, and post-deploy measurement — but still label claims Verified / Checked / Assumed
|
||||
honestly in the report.
|
||||
|
||||
**Full loop** — performance/RAM/CPU work, diagnosis ("why is X slow/broken"), anything changing
|
||||
a shipped default, changes spanning several scripts or mechanisms (a version bump's
|
||||
config.yaml + CHANGELOG + Dockerfile touch is still light), anything at ladder levels 4–6, or
|
||||
when the user asks for a Codex check.
|
||||
|
||||
**Escalation rule** — if a light task grows mid-flight (touches a default, needs a new script or
|
||||
service, reveals a deeper problem), stop and upgrade to the full loop rather than continuing
|
||||
light.
|
||||
|
||||
`references/traps.md` is required reading on **both** paths — traps bite one-liners too.
|
||||
|
||||
## 1. Scope before you work
|
||||
|
||||
Write this down before generating anything. Two sentences is enough, but they must be explicit:
|
||||
|
||||
- **Goal** — the observable outcome, in the user's terms.
|
||||
- **Non-goals** — what you are deliberately not touching. This is the load-bearing half.
|
||||
- **Constraints** — what cannot change (defaults for other users, upgrade paths, arch support).
|
||||
- **Definition of done** — what evidence will demonstrate success.
|
||||
|
||||
A diagnosis task and a fix task have different scopes. "Why is it slow?" is answered by
|
||||
measurement and a written finding; it does not automatically authorize a rewrite. When the user's
|
||||
ask genuinely spans several changes, list them and say which you are doing now.
|
||||
|
||||
Ask about defaults when the answer changes who is affected. Changing a shipped default touches
|
||||
every user of a 120-add-on repo; making it opt-in touches nobody until they choose. That is the
|
||||
user's call, not yours, and it is cheap to ask before implementing rather than after.
|
||||
|
||||
## 2. Evidence before reasoning
|
||||
|
||||
State no cause you have not observed. On a live add-on (`$BUILD_VERSION` set, `HOME=/data/data`)
|
||||
measure the running system rather than reasoning from source — reviewers will hold you to the
|
||||
numbers, and source-derived guesses are where wrong plans come from.
|
||||
|
||||
Pick the tool to the question:
|
||||
|
||||
- **"what is consuming RAM/CPU?"** → `scripts/measure.sh` (PSS and private, not summed RSS; keep
|
||||
the sample at ≥20 s — a 3 s sample measured 2.3% where 20 s measured 21.6%).
|
||||
- **"I set an option and nothing happened"** → `scripts/env_trace.sh <VAR> <process>`, which
|
||||
walks all four stages of the option plumbing and names the one that dropped the value.
|
||||
- **"is this flag/driver/package actually present?"** → look at the artifact itself:
|
||||
`/proc/<pid>/cmdline`, `command -v`, `/var/log/apt/history.log`.
|
||||
|
||||
`references/traps.md#measurement` explains why summed RSS and reserved-vs-resident both matter.
|
||||
|
||||
Before asserting anything, ask what would show it false, then go look:
|
||||
|
||||
- "This process is duplicated" → is it? `ps -ef --forest`, compare parents and start times.
|
||||
- "This costs 500 MB" → is it resident? `grep Rss /proc/<pid>/smaps`.
|
||||
- "This block never runs" → is its payload in the image? `command -v`, `apt` history.
|
||||
- "The flag isn't set" → `tr '\0' '\n' < /proc/<pid>/cmdline`.
|
||||
|
||||
**Verify the revision you are reading.** `scripts/preflight.sh` compares the checkout's
|
||||
`config.yaml` version against the running `$BUILD_VERSION`. A stale branch reads as completely
|
||||
normal and has already cost one full analysis pass.
|
||||
|
||||
When you correct yourself mid-analysis, keep the correction visible in your notes and in what you
|
||||
report. A retracted claim that stays retracted is worth more than one quietly dropped.
|
||||
|
||||
## 3. Plan, then have Codex attack it
|
||||
|
||||
### Choose the mechanism level first (both paths)
|
||||
|
||||
Rank mechanisms and start from the top — choose the level **before writing code** and state it
|
||||
in the plan. Each step down costs more to write, more to review, and more to keep working across
|
||||
base-image upgrades:
|
||||
|
||||
1. **A config value** — an option, a schema constraint, an existing env var.
|
||||
2. **An existing knob** the base image already reads (`MAX_RES`, `DRINODE`, `SELKIES_*`).
|
||||
3. **A few lines in an existing script**, at the point that already runs.
|
||||
4. **A new init script.**
|
||||
5. **A new service, wrapper, or long-running process.**
|
||||
6. **Custom protocol code, or patching someone else's internals.**
|
||||
|
||||
Levels 4–6 automatically mean full loop, and need a reason that survives being said out loud.
|
||||
"Upstream has no knob for this, and I checked" is a reason. "It felt cleaner" is not. If two
|
||||
levels both solve it, the higher (simpler) level wins even when the lower one would be more
|
||||
efficient — see the standing rule at the top.
|
||||
|
||||
### Codex review of the plan (full loop only — skip on the light path)
|
||||
|
||||
Write the plan around the measurements — each proposed change tied to a number — then get an
|
||||
independent read **before** writing code. Codex is a genuinely different model reading the files
|
||||
itself; on this workload it has repeatedly been worth the minutes.
|
||||
|
||||
**Use the CLI, not the MCP tool, for prompts of this size.** `mcp__codex__codex` timed out
|
||||
twice on ~4 KB prompts (2026-08-03); the CLI with the same content succeeded. This overrides the
|
||||
global CLAUDE.md note recommending the MCP tool — that guidance still holds for short questions.
|
||||
Run it backgrounded (`--sandbox read-only` means Codex cannot run anything, so paste every number
|
||||
into the prompt; `- <` feeds the prompt file on stdin):
|
||||
|
||||
```bash
|
||||
codex exec --model gpt-5.6-sol --sandbox read-only --skip-git-repo-check \
|
||||
-c approval_policy='"never"' - < prompt.md > codex_out.txt 2>&1
|
||||
```
|
||||
|
||||
Write the prompt to a file. Include the files to read, your measurements **with numbers**, the
|
||||
proposed changes, and explicit instructions to challenge you. Ask direct questions ("is this
|
||||
really add-on-fixable?", "give the precise flag set") rather than "review this". Codex's sandbox
|
||||
often cannot run local commands and falls back to reading GitHub, so paste the evidence in rather
|
||||
than assuming it will find it.
|
||||
|
||||
**Codex agrees with confident premises.** It has confirmed a wrong conclusion stated too
|
||||
confidently, and separately caught a genuine methodology error in the same review. Treat its
|
||||
confirmations with the same scepticism as its objections — especially about the build.
|
||||
|
||||
### Attack your own plan too
|
||||
|
||||
Before implementing, spend a moment actively trying to break it:
|
||||
|
||||
- What does this do on a host **unlike this one** — no GPU, small `/dev/shm`, aarch64, a VM?
|
||||
- What happens on **upgrade** to someone who configured this by hand?
|
||||
- What is the **blast radius** if the assumption underneath it is wrong?
|
||||
- What am I **inferring** that I could instead **detect at runtime** or **record explicitly**?
|
||||
|
||||
That last question is the highest-yield one here; see [the recurring failure
|
||||
mode](#the-failure-mode-this-loop-keeps-producing).
|
||||
|
||||
## 4. Implement
|
||||
|
||||
Read `references/traps.md` first; the bashio, s6-env, arch-guard and versioning traps are all
|
||||
live and each has shipped a bug.
|
||||
|
||||
Validate with `scripts/validate.sh <addon> --vs-master`.
|
||||
|
||||
Write behavioural tests for anything with branches. Extract an embedded Python heredoc and drive
|
||||
it against fixtures with stubbed env vars; stub `bashio::*` and `df` to exercise shell paths. Test
|
||||
**the regression a reviewer described**, not just the happy path — a test that only covers the
|
||||
case you were already thinking about adds little.
|
||||
|
||||
## 5. Simplify — is this the simplest thing that works?
|
||||
|
||||
Do this once you have something working and before you ask anyone to review it. The question is
|
||||
not "is this good code" but **"what is the smallest change that makes the symptom go away, and
|
||||
why isn't that enough?"** If you cannot answer the second half, the smaller change is the answer.
|
||||
And restating the standing rule: obviously correct and slightly less efficient beats faster and
|
||||
harder to review — efficiency only buys complexity when a measurement shows it matters.
|
||||
|
||||
Checks worth running against your own diff:
|
||||
|
||||
- **Did the diff stay at the ladder level chosen in step 3?** If it crept up a level, either
|
||||
justify that out loud or redo it at the level you chose.
|
||||
- **Can this be solved by deleting instead of adding?** A flag that shouldn't be passed, a
|
||||
process that shouldn't start, a registration that shouldn't be duplicated. Removals cannot
|
||||
regress on hosts you can't test.
|
||||
- **Is the fix bigger than the thing it fixes?** That is a smell, not a rule — but it usually
|
||||
means the problem was framed one level too deep.
|
||||
- **How does this fail in three years**, when the base image, Electron, or upstream has moved?
|
||||
Code that reads a documented knob keeps working. Code that reaches into private internals
|
||||
does not.
|
||||
|
||||
The evidence from this repo is blunt:
|
||||
|
||||
- A rejected PR spent a **388-line TCP proxy plus a 142-line monkeypatch of a private upstream
|
||||
method** to reclaim 159 MB — placing custom transport code in the path of every API request.
|
||||
Both independent reviewers said close it rather than iterate on it.
|
||||
- A ~180-line `ctypes` probe was written to decide whether to enable GPU flags. It worked
|
||||
perfectly, proved the driver was fine, and the change **still did nothing**, because the
|
||||
question it answered was not the question that mattered.
|
||||
- A resolution cap shipped as a **new init script writing an s6 envdir** — the wrong mechanism
|
||||
entirely. Renaming the option to the env var the service already reads (level 1) would have
|
||||
worked, and the new script did not.
|
||||
|
||||
In all three cases the simpler option existed and was skipped. Being able to build the
|
||||
complicated thing is not a reason to.
|
||||
|
||||
## 6. Codex attacks the code (full loop only)
|
||||
|
||||
Same invocation, pointed at `git diff origin/master...HEAD` plus the reasoning behind each hunk.
|
||||
Ask specifically what breaks: upgrade paths, hosts unlike this one, users who configured things
|
||||
by hand. Ask it directly whether a simpler mechanism would achieve the same thing — an outside
|
||||
reader spots one-level-too-deep framing far more easily than the person who just built it.
|
||||
|
||||
## 7. Open the PR
|
||||
|
||||
What CI actually gates on a PR: **`CHANGELOG.md` updated** (hard `exit 1`), the **HA add-on
|
||||
linter** (`frenck/action-addon-linter` in `onpr_check-pr.yaml`, no `continue-on-error` — a
|
||||
config.yaml schema error blocks the PR), and the **add-on image build**. The non-blocking lint
|
||||
is the *weekly Super-Linter*, not the PR checks — don't confuse the two. Nothing checks the
|
||||
version bump — but bump it
|
||||
anyway (`X.Y.Z.N`, never `X.Y.Z-N`, see `references/traps.md#versioning`), because Supervisor will
|
||||
not offer a rebuild without it, and update `README.md` if you added options. Match the existing
|
||||
CHANGELOG heading format, `## X.Y (DD-MM-YYYY)`.
|
||||
|
||||
Write the body to a file and use `gh pr create --body-file`. State what was measured, what
|
||||
changed, **what is not verified**, and how to roll back the riskiest hunk on its own.
|
||||
|
||||
## 8. Resolve review comments
|
||||
|
||||
`scripts/pr_review.sh list|reply|resolve|status|watch <PR>`.
|
||||
|
||||
For every comment, **reproduce the claim before agreeing or disagreeing.** A CodeRabbit finding
|
||||
that `grep -E '^…$'` anchors per line — letting a multi-line value pass validation — was real and
|
||||
provable in one command. A finding that a changelog heading needed a blank line was a false
|
||||
positive against this repo's `.markdownlint.yaml`.
|
||||
|
||||
Reply with the evidence, then resolve the thread. **Push back when you are right**, on the thread,
|
||||
so the maintainer can overrule you — a resolved-but-wrong thread is worse than an open one.
|
||||
Equally, when a reviewer is right, fix the cause rather than papering over the symptom.
|
||||
|
||||
## 9. Verify before declaring done
|
||||
|
||||
Do not write "this should work". Either it was exercised, or say plainly that it wasn't.
|
||||
|
||||
On the light path, verification is `validate.sh` plus CI — anything beyond that is **Assumed**,
|
||||
and the report must say so plainly.
|
||||
|
||||
Distinguish three states and never let them blur:
|
||||
|
||||
- **Verified** — you ran it and observed the result.
|
||||
- **Checked but not exercised** — it parses, lints, type-checks.
|
||||
- **Assumed** — reasoning only. Name the assumption.
|
||||
|
||||
**CI passing and the PR merging prove the build works, not that the change does anything.** Once
|
||||
the rebuilt add-on is running, re-run the measurement that motivated the work. Both changes in the
|
||||
session that produced this skill passed CI, merged, and were **inert**:
|
||||
|
||||
- The Xvfb resolution cap wrote its env file correctly and Xvfb still started at the base-image
|
||||
default — wrong env mechanism for that service.
|
||||
- The GPU flags reached Chromium's command line exactly as intended, and the GPU process still
|
||||
reported `--use-gl=disabled`, having overridden them after its own init failed.
|
||||
|
||||
Cheap post-deploy checks: `tr '\0' '\n' < /proc/<pid>/cmdline` for flags, `/proc/<pid>/environ`
|
||||
for env vars, `scripts/env_trace.sh <VAR> <process>` for the whole option plumbing, and a repeat
|
||||
CPU/PSS sample against the pre-change numbers.
|
||||
|
||||
Some fixes cannot be self-verified. A service reads its environment only at start, so an env-var
|
||||
fix is unproven until the add-on restarts — which needs the user, or `ha-cli` with their
|
||||
agreement. If you cannot restart, the change is **Assumed**, not Verified, and must be reported
|
||||
that way.
|
||||
|
||||
## 10. Calibrate and report
|
||||
|
||||
Close against the scope from step 1, not against what you ended up doing. Structure:
|
||||
|
||||
```
|
||||
What was asked / what shipped — mapped to the original scope
|
||||
Evidence — the numbers, before and after
|
||||
Verified — observed, with how
|
||||
Not verified — and why (e.g. no dockerd locally; CI is the gate)
|
||||
Known broken / left out — explicitly, including anything descoped
|
||||
Risk + rollback — the riskiest hunk and how to revert it alone
|
||||
```
|
||||
|
||||
Lead with anything that did not work. A merged PR that achieved nothing is the single most
|
||||
important sentence in the report, and it must not appear after the summary of what went well.
|
||||
|
||||
Give confidence per claim, not one blanket number, and make it mean something: "measured", "CI
|
||||
verified", "unverified — reasoning only". If a number came from one host, say so.
|
||||
|
||||
---
|
||||
|
||||
## The failure mode this loop keeps producing
|
||||
|
||||
Every bug shipped from the source session came from one move: **measuring this host correctly,
|
||||
then generalising it to all hosts.**
|
||||
|
||||
- `/dev/shm` was 7.7 GB here, so a flag looked useless — but Home Assistant ignores `shm_size`, so
|
||||
elsewhere it is Docker's 64 MB default and removing the flag reintroduces a crash loop.
|
||||
- An MCP entry was identified by its URL — but that URL is the documented default, so the rule
|
||||
would have deleted a user's hand-written configuration.
|
||||
- A GPU probe created a hardware context — but that proved the driver worked, not that Chromium's
|
||||
GPU path did.
|
||||
|
||||
The pattern is always *inference standing in for detection*. Before changing a default, ask what
|
||||
this is like on a host unlike yours. Prefer detecting the condition at runtime over asserting it.
|
||||
When ownership matters, **record it rather than infer it**.
|
||||
|
||||
## Token efficiency
|
||||
|
||||
`rtk` wraps commands via hook automatically. Compress large structured output you will re-read
|
||||
with `mcp__headroom__headroom_compress` (skip error/stack output). Use
|
||||
`mcp__tokensave__tokensave_context` for code exploration. Redirect big output to a file and read
|
||||
only what you need, and poll CI in a **background** task rather than blocking.
|
||||
|
||||
## Bundled files
|
||||
|
||||
| File | Use |
|
||||
|---|---|
|
||||
| `scripts/preflight.sh` | Tools, live-add-on check, revision-vs-running-image check. Exits 2 on mismatch |
|
||||
| `scripts/measure.sh` | RAM (PSS/private) + CPU snapshot; reserved vs resident. Sample ≥20 s |
|
||||
| `scripts/env_trace.sh` | Trace one env var through all four plumbing stages — for "my option did nothing" |
|
||||
| `scripts/validate.sh` | Local linters + CI gates; `--vs-master` shows only findings your diff added |
|
||||
| `scripts/pr_review.sh` | Fetch / reply to / resolve PR review threads; watch checks |
|
||||
| `references/traps.md` | Repo-specific traps — read before implementing |
|
||||
|
||||
Each script's header explains its reasoning; read the script when you use it.
|
||||
212
.claude/skills/hassio-addon-workflow/references/traps.md
Normal file
212
.claude/skills/hassio-addon-workflow/references/traps.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Repo-specific traps
|
||||
|
||||
Things that look correct and are not. Each cost real time or shipped broken. Read this before
|
||||
implementing; skim the headings, read the ones you're about to touch.
|
||||
|
||||
The repo's own `CLAUDE.md` documents structure, Dockerfile conventions, `updater.json`, CI
|
||||
workflows and lint rules — that is not repeated here.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Environment and workspace](#environment-and-workspace)
|
||||
- [Measurement](#measurement)
|
||||
- [Passing values into base-image services](#passing-values-into-base-image-services)
|
||||
- [Shell and bashio](#shell-and-bashio)
|
||||
- [Dockerfile and architecture](#dockerfile-and-architecture)
|
||||
- [Versioning](#versioning)
|
||||
- [Chromium / Electron under Xvfb](#chromium--electron-under-xvfb)
|
||||
- [CI and review bots](#ci-and-review-bots)
|
||||
|
||||
---
|
||||
|
||||
## Environment and workspace
|
||||
|
||||
**The checkout is probably on the wrong branch.** Checkouts under `/data/claude` are shared and
|
||||
persistent; another session leaves them wherever it finished. A stale branch looks entirely
|
||||
normal. Compare the add-on's `config.yaml` `version` against the running `$BUILD_VERSION` before
|
||||
trusting anything you read. `scripts/preflight.sh` does this.
|
||||
|
||||
**Never run `git stash` under `/data/claude`.** `refs/stash` is shared across every worktree and
|
||||
concurrent session, so it is *not* isolated even in your own worktree. A bare `stash` / `stash
|
||||
pop` pair in a clean worktree once restored another session's stash, producing conflict markers
|
||||
in six untouched files. To compare a file against another revision use
|
||||
`git show <rev>:<path> > /tmp/x`. If a pop does go wrong: a conflicted pop **keeps** the stash
|
||||
entry, so nothing is lost — confirm `git rev-parse HEAD` matches what you pushed, then
|
||||
`git reset --hard HEAD`.
|
||||
|
||||
**Work in a worktree under `/data`, not `/tmp`** — `/tmp` is `noexec`, so scripts there won't run.
|
||||
|
||||
```bash
|
||||
git worktree add --detach /data/claude/.work/<task> origin/master
|
||||
```
|
||||
|
||||
**You cannot test the Docker build.** dockerd does not start in this environment. CI is the only
|
||||
gate. One observed run took ~3 hours, with 20+ runs queued against 2 executing — that was account
|
||||
runner contention, not the diff. Check `gh run list` before concluding your PR is stuck. Poll in
|
||||
a background task, and never claim the build is verified when it hasn't run.
|
||||
|
||||
## Measurement
|
||||
|
||||
**Summed RSS overstates savings.** Shared library pages are counted once per process, so removing
|
||||
a duplicate frees its *private* memory, not its RSS. Measured example: four MCP shims summed to
|
||||
882 MB RSS but 643 MB PSS / 564 MB private, and per-process private ranged 54 MB down to 2 MB —
|
||||
which completely changes which duplicate is worth removing. Quote private when arguing "removing
|
||||
this saves N MB".
|
||||
|
||||
**A large mapping is often not resident.** SysV/tmpfs segments are lazily populated. Xvfb's
|
||||
506 MB framebuffer shows `Rss: 0` in `/proc/<pid>/smaps`. Check before calling anything a leak.
|
||||
|
||||
**`/proc/meminfo` and `free` show host figures** — there is no memory cgroup namespace here.
|
||||
Never attribute those totals to the add-on.
|
||||
|
||||
**`rtk` filters some command output.** For a complete listing, redirect to a file and read that
|
||||
(`ps ... > $SP/ps.txt`), or use `rtk proxy <cmd>`.
|
||||
|
||||
## Passing values into base-image services
|
||||
|
||||
The plumbing has four stages. `scripts/env_trace.sh <VAR> <process>` walks all four and tells
|
||||
you which one drops the value — use it rather than reasoning about this from memory.
|
||||
|
||||
1. `/data/options.json` — the user's saved options.
|
||||
2. **Injected export block** — `.templates/00-global_var.sh` writes a literal
|
||||
`export <option>='<value>'` block into *every* service `run` script, using the option name
|
||||
**verbatim**. So `max_resolution` *is* injected; it just isn't a name any service reads.
|
||||
`MAX_RES` would be both injected and read.
|
||||
3. `container_environment` — s6's envdir, read **only** by services whose shebang is
|
||||
`#!/usr/bin/with-contenv`.
|
||||
4. The running process — the only stage that decides behaviour.
|
||||
|
||||
**Name the option exactly as the env var the service reads** (uppercase), the way `DRINODE`,
|
||||
`KEYBOARD` and `TZ` already do. Verified live: `DRINODE` appears as `export DRINODE=…` in all 16
|
||||
service run scripts including `svc-xorg`, and Xvfb runs with `-vfbdevice /dev/dri/renderD128`.
|
||||
|
||||
**Two consequences that are easy to get wrong:**
|
||||
|
||||
- `00-global_var.sh` is cont-init **00**. Any cont-init script numbered higher runs *after* the
|
||||
injection, so it cannot change what a service will see through stage 2.
|
||||
- LSIO's `svc-xorg` starts `#!/usr/bin/env bashio`, **not** `with-contenv`, so it never reads
|
||||
stage 3 at all. Writing `container_environment` for it is a silent no-op — that shipped: the
|
||||
file was written 6 seconds before Xvfb started, and Xvfb still came up at the base-image
|
||||
default.
|
||||
|
||||
**Renaming an option to match a base-image env var moves validation out of your script and into
|
||||
the schema.** `00-global_var.sh` exports empty strings (only objects/arrays/nulls are dropped),
|
||||
and base-image scripts typically test `${VAR+x}` — *set*-ness, not emptiness. So an empty
|
||||
`MAX_RES` becomes `Xvfb -screen 0 "x24"` and the X server does not start. If you make this move,
|
||||
constrain the value in `config.yaml` (`match(^[0-9]{1,5}x[0-9]{1,5}$)?`) in the same commit, or
|
||||
keep a guard script.
|
||||
|
||||
**Open question, unresolved:** what Supervisor does with a stored `options.json` key that no
|
||||
longer exists in the new schema — error, warn, or silently drop. This decides whether renaming an
|
||||
option is safe on upgrade. The `monica` add-on shipped exactly such a rename
|
||||
(`MEILISEARCH_KEY` → `meilisearch_key`) with no migration, which is weak evidence it is
|
||||
tolerated. The base image has an `init-migrations` oneshot reading `/migrations` if a migration
|
||||
is needed. Confirm before renaming a shipped option.
|
||||
|
||||
Whichever mechanism you use, verify the service actually received it:
|
||||
|
||||
```bash
|
||||
tr '\0' '\n' < /proc/<pid>/environ | grep <VAR>
|
||||
```
|
||||
|
||||
**`cont-init.d` runs as root before s6 services start** — that part is true and is the right
|
||||
place for filesystem and permission setup.
|
||||
|
||||
**Anything needing an X display must not run in `cont-init.d`** — Xvfb isn't up yet. Put it in
|
||||
the openbox autostart. (ANGLE's OpenGL backend, for instance, fails with "Could not open the
|
||||
default X display".)
|
||||
|
||||
## Shell and bashio
|
||||
|
||||
**`bashio::config` for lists**: `while read ... < <(bashio::config ...)` silently yields an empty
|
||||
list under errexit — bashio's internals return non-zero and process substitution inherits the
|
||||
failure. Capture with `$(...)` first, then feed a here-string.
|
||||
|
||||
**Scripts shared by symlink**: `80-configuration.sh` and friends are shared with the webtop
|
||||
add-ons. Put add-on-specific logic in a new numbered script instead of editing them.
|
||||
|
||||
**`grep -E '^…$'` anchors per line.** A multi-line config value passes validation on its first
|
||||
line and is then used verbatim. Use bash's `[[ =~ ]]`, which anchors the whole string.
|
||||
|
||||
## Dockerfile and architecture
|
||||
|
||||
**Prefer `BUILD_ARCH` over `TARGETARCH`** — the repo's builder passes `BUILD_ARCH` explicitly,
|
||||
while `TARGETARCH` is BuildKit-provided and may or may not be populated.
|
||||
|
||||
Either way the variable must be declared with `ARG <NAME>` **in the build stage that uses it**;
|
||||
without that it expands empty, the guard never matches, and the block silently does nothing —
|
||||
which is the same dead-`if` failure described just below, and the usual cause of it.
|
||||
|
||||
**Verify a guarded block actually ran** rather than assuming. Check whether its payload exists in
|
||||
the running image (`command -v <tool>`), and cross-check `/var/log/apt/history.log` for the
|
||||
matching `apt-get install` line. An `if` block whose condition never matched leaves no trace and
|
||||
no error — one such block sat dead for weeks while appearing to guarantee driver verification.
|
||||
|
||||
**Don't test for distro-specific filenames.** A guard on
|
||||
`/usr/share/vulkan/icd.d/intel_icd.x86_64.json` named a file Debian does not ship (it installs
|
||||
`intel_icd.json`), so fixing the arch variable alone would have turned dead code into a failing
|
||||
build.
|
||||
|
||||
## Versioning
|
||||
|
||||
**`X.Y.Z.N`, never `X.Y.Z-N`.** A hyphen parses as a semver pre-release, which Supervisor treats
|
||||
as *older* than `X.Y.Z` — the update is never offered.
|
||||
|
||||
Date-based versions (`2026.08.03`) are common here. Check whether master has already moved to the
|
||||
version you were about to use.
|
||||
|
||||
## Chromium / Electron under Xvfb
|
||||
|
||||
**Xvfb offers only indirect/software GLX**, so Chromium probes it, fails, and falls back to CPU
|
||||
rendering — the GPU process runs `--use-gl=disabled` and the renderer `--disable-gpu-compositing`.
|
||||
|
||||
**Passing ANGLE flags is not sufficient.** `--ozone-platform=x11 --use-gl=angle
|
||||
--use-angle=gl-egl` reached Chromium's command line exactly as intended and the GPU process
|
||||
*still* reported `--use-gl=disabled`, having overridden the flag after its own init failed.
|
||||
|
||||
**A standalone ANGLE probe proves less than it appears to.** Loading Claude Desktop's bundled
|
||||
`libEGL.so`, initializing the OpenGL backend and reading back
|
||||
`ANGLE (Intel, Mesa Intel(R) Graphics (ADL-N), OpenGL 4.6)` proves the driver and device work —
|
||||
not that Chromium's GPU process, sandbox, dmabuf import and X11 presentation path work. Codex
|
||||
flagged this distinction during review and was right.
|
||||
|
||||
`--use-angle=gles-egl` is rejected outright by Mesa ("Intel or NVIDIA OpenGL ES drivers are not
|
||||
supported").
|
||||
|
||||
**`--disable-dev-shm-usage`** is a workaround for Docker's 64 MB default `/dev/shm`. Home
|
||||
Assistant **ignores** the add-on's `shm_size`, so the real size varies per install — it was 7.7 GB
|
||||
on one host. Detect at runtime rather than assuming either way; keep the flag when the size
|
||||
cannot be determined, because the crash it prevents is worse than its overhead.
|
||||
|
||||
## CI and review bots
|
||||
|
||||
**What CI actually gates** — checked against the workflows, because assuming costs a cycle:
|
||||
|
||||
- **CHANGELOG updated** — the only hard gate (`onpr_check-pr.yaml`, its single `exit 1`).
|
||||
- **Add-on image build** — real, and slow; one run took ~3 h.
|
||||
- **Lint** — `lint.yml` runs super-linter with `continue-on-error: true` at both call sites, so
|
||||
it *cannot* fail a PR. Fix real findings anyway, but do not treat lint as a blocker.
|
||||
- **Version bump** — no workflow checks it. It is repo convention, and required for Supervisor to
|
||||
offer the rebuild, but it will not fail CI.
|
||||
|
||||
**CI rewrites your shell scripts.** `lint.yml` runs `shfmt -w -i 4 -ci -bn -sr` over every `*.sh`
|
||||
and `run`, plus a `chmod +x` pass, on schedule. Repo-wide reformatting commits land on master
|
||||
without your involvement — another reason a shared checkout goes stale mid-task.
|
||||
|
||||
**Reviewers**: CodeRabbit (deepest — often runs scripts to prove a claim; reviews ~9 minutes
|
||||
after the PR opens, or on `@coderabbitai review`), chatgpt-codex-connector, Copilot, Codacy.
|
||||
|
||||
**Codacy `action_required` is this repo's normal state.** Other open PRs show the same. It
|
||||
exposes no annotations via the API, so its findings are only visible in the maintainer's Codacy
|
||||
account. Note it and move on rather than guessing.
|
||||
|
||||
**Resolving a review thread requires GraphQL** (`resolveReviewThread`); the REST API cannot do it.
|
||||
`scripts/pr_review.sh` wraps fetch / reply / resolve.
|
||||
|
||||
**The repo's `.markdownlint.yaml` does not disable MD022/MD032**, so a CHANGELOG will show
|
||||
dozens of pre-existing heading/list findings. They are noise because lint is `continue-on-error`,
|
||||
not because the config exempts them — don't cite the config as a reason to ignore a finding.
|
||||
|
||||
**Separate new lint findings from pre-existing ones** by linting the same file at `origin/master`
|
||||
and diffing the result sets — otherwise you chase warnings that were already there.
|
||||
`scripts/validate.sh --vs-master` does this.
|
||||
136
.claude/skills/hassio-addon-workflow/scripts/env_trace.sh
Executable file
136
.claude/skills/hassio-addon-workflow/scripts/env_trace.sh
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trace one env var through the whole add-on plumbing, to answer "I set the option and nothing
|
||||
# happened".
|
||||
#
|
||||
# This is the single highest-value diagnostic for this repo, because the plumbing has four
|
||||
# separate stages and a value can be present at stage 3 and absent at stage 4 while every script
|
||||
# involved reports success. That exact case shipped: MAX_RES was correctly written to
|
||||
# /run/s6/container_environment/MAX_RES six seconds before Xvfb started, and Xvfb still came up
|
||||
# at the base-image default.
|
||||
#
|
||||
# The four stages:
|
||||
# 1. /data/options.json the user's saved add-on options
|
||||
# 2. injected export block .templates/00-global_var.sh writes `export <option>='<v>'`
|
||||
# into every service run script — as cont-init 00, i.e. BEFORE
|
||||
# any higher-numbered cont-init script can influence it
|
||||
# 3. container_environment s6's envdir, read only by services using `with-contenv`
|
||||
# 4. the running process the only stage that actually matters
|
||||
#
|
||||
# Two consequences worth internalising:
|
||||
# * A cont-init.d script numbered >00 cannot change what stage 2 injected.
|
||||
# * A service starting `#!/usr/bin/env bashio` (LSIO's svc-xorg does) never reads stage 3, so
|
||||
# writing container_environment for it is a silent no-op.
|
||||
#
|
||||
# Usage: env_trace.sh <VAR> [process-name-or-pid]
|
||||
# env_trace.sh MAX_RES Xvfb
|
||||
# env_trace.sh DRINODE Xvfb # a working example, for comparison
|
||||
set -uo pipefail
|
||||
|
||||
VAR="${1:?usage: env_trace.sh <VAR> [process-name-or-pid]}"
|
||||
TARGET="${2:-}"
|
||||
# VAR is interpolated into grep/sed patterns below — restrict it to a valid env var name
|
||||
case "$VAR" in
|
||||
[A-Za-z_]*) [ -z "${VAR//[A-Za-z0-9_]/}" ] || { echo "invalid env var name: $VAR" >&2; exit 1; } ;;
|
||||
*) echo "invalid env var name: $VAR" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "== tracing ${VAR} =="
|
||||
echo
|
||||
|
||||
echo "1. /data/options.json (the user's saved options)"
|
||||
if [ -f /data/options.json ]; then
|
||||
python3 - "$VAR" <<'PY'
|
||||
import json, sys
|
||||
var = sys.argv[1]
|
||||
try:
|
||||
opts = json.load(open('/data/options.json'))
|
||||
except Exception as err:
|
||||
print(f" could not parse: {err}"); raise SystemExit
|
||||
hit = {k: v for k, v in opts.items() if k.lower() == var.lower()}
|
||||
if hit:
|
||||
for k, v in hit.items():
|
||||
shown = '<empty string>' if v == '' else repr(v)
|
||||
print(f" {k} = {shown}")
|
||||
if k != var:
|
||||
print(f" NOTE: option is named '{k}', not '{var}' — the injected export uses the")
|
||||
print(f" option name verbatim, so a service reading ${var} will not see it.")
|
||||
else:
|
||||
print(f" absent (so the add-on default from config.yaml applies, if any)")
|
||||
PY
|
||||
else
|
||||
echo " /data/options.json not present (not running as an add-on?)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "2. injected 'ADDON ENV' export block in service run scripts"
|
||||
found=0
|
||||
for d in /etc/s6-overlay/s6-rc.d /etc/services.d; do
|
||||
[ -d "$d" ] || continue
|
||||
while IFS= read -r rs; do
|
||||
if grep -qE "^export ${VAR}=" "$rs" 2> /dev/null; then
|
||||
echo " $(grep -E "^export ${VAR}=" "$rs" | head -1) <- $rs"
|
||||
found=1
|
||||
fi
|
||||
done < <(find "$d" -name run -type f 2> /dev/null)
|
||||
done
|
||||
[ "$found" -eq 0 ] && echo " ${VAR} not injected into any service run script"
|
||||
|
||||
echo
|
||||
echo "3. s6 container_environment (only read by services using #!/usr/bin/with-contenv)"
|
||||
seen3=0
|
||||
for d in /var/run/s6/container_environment /run/s6/container_environment; do
|
||||
if [ -f "$d/$VAR" ]; then
|
||||
echo " $d/$VAR = [$(cat "$d/$VAR")]"; seen3=1
|
||||
fi
|
||||
done
|
||||
[ "$seen3" -eq 0 ] && echo " not present in either envdir"
|
||||
|
||||
echo
|
||||
echo "4. the running process (the only stage that decides behaviour)"
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo " no target given; pass a process name or pid as \$2"
|
||||
else
|
||||
# Prefer an exact process-name match. A -f substring match picks up this script's own shell
|
||||
# (its command line contains the name you searched for), which produces confusing noise.
|
||||
if [ -d "/proc/$TARGET" ]; then
|
||||
pids="$TARGET"
|
||||
else
|
||||
pids=$(pgrep -x "$TARGET" 2> /dev/null | head -3)
|
||||
[ -z "$pids" ] && pids=$(pgrep -f "$TARGET" 2> /dev/null | grep -vE "^($$|$PPID)$" | head -3)
|
||||
fi
|
||||
if [ -z "$pids" ]; then
|
||||
echo " no process matching '$TARGET'"
|
||||
else
|
||||
for pid in $pids; do
|
||||
comm=$(tr -d '\0' < "/proc/$pid/comm" 2> /dev/null)
|
||||
val=$(tr '\0' '\n' < "/proc/$pid/environ" 2> /dev/null | sed -n "s/^${VAR}=//p")
|
||||
if [ -n "$val" ]; then
|
||||
echo " pid=$pid ($comm): ${VAR}=[$val]"
|
||||
else
|
||||
echo " pid=$pid ($comm): ${VAR} NOT SET"
|
||||
# Naming the shebang is usually the whole answer.
|
||||
for d in /etc/s6-overlay/s6-rc.d /etc/services.d; do
|
||||
while IFS= read -r rs; do
|
||||
if grep -qiE "exec .*${comm}|${comm}" "$rs" 2> /dev/null; then
|
||||
echo " its service $rs starts: $(head -1 "$rs")"
|
||||
head -1 "$rs" | grep -q with-contenv \
|
||||
&& echo " -> uses with-contenv, so stage 3 WOULD reach it" \
|
||||
|| echo " -> NOT with-contenv, so stage 3 can never reach it"
|
||||
break 2
|
||||
fi
|
||||
done < <(find "$d" -name run -type f 2> /dev/null)
|
||||
done
|
||||
fi
|
||||
# What it was actually launched with beats any theory about its environment.
|
||||
tr '\0' '\n' < "/proc/$pid/cmdline" 2> /dev/null | tail -n +2 |
|
||||
grep -iE "res|screen|${VAR}" | head -3 | sed 's/^/ argv: /'
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== reading the result =="
|
||||
echo " present at 4 -> the value reached the process; the bug is elsewhere"
|
||||
echo " at 1+2 but not 4 -> service started before injection, or reads a different name"
|
||||
echo " at 1+3 but not 2 or 4 -> classic silent no-op: wrong mechanism for this service"
|
||||
echo " at 1 only -> option name does not match any env var a service reads"
|
||||
99
.claude/skills/hassio-addon-workflow/scripts/measure.sh
Executable file
99
.claude/skills/hassio-addon-workflow/scripts/measure.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# RAM/CPU snapshot of the running add-on, built to avoid the two mistakes that make such
|
||||
# snapshots wrong:
|
||||
#
|
||||
# 1. Summed RSS double-counts shared pages. Removing a duplicate process frees its *private*
|
||||
# memory, not its RSS. So PSS and private are reported alongside, and private is the number
|
||||
# to quote when arguing "removing this saves N MB".
|
||||
# 2. A big mapping is not necessarily resident. Large SysV/tmpfs segments are lazily populated,
|
||||
# so reserved size is reported separately from resident.
|
||||
#
|
||||
# Note /proc/meminfo and free show HOST figures (no memory cgroup namespace) — never attribute
|
||||
# those to the add-on.
|
||||
#
|
||||
# Usage: measure.sh [cpu-sample-seconds] (default 20)
|
||||
set -uo pipefail
|
||||
SAMPLE="${1:-20}"
|
||||
if [ "$SAMPLE" -lt 20 ]; then
|
||||
echo "WARNING: a ${SAMPLE}s sample understates CPU badly (a 3s sample measured 2.3% where" >&2
|
||||
echo " 20s measured 21.6% for the same process). Use >=20s for anything you report." >&2
|
||||
fi
|
||||
OUT="${SCRATCH:-${TMPDIR:-/tmp}}/addon-measure.$$"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# rtk filters some output; redirect to a file to get the complete list.
|
||||
ps -eo pid,ppid,user,rss,pcpu,etimes,args --sort=-rss > "$OUT/ps.txt" 2>&1
|
||||
|
||||
echo "== totals =="
|
||||
awk 'NR>1{s+=$4; n++} END{printf " processes=%d summed RSS=%.0f MB (overstates: shared pages counted per-process)\n", n, s/1024}' "$OUT/ps.txt"
|
||||
awk '{t+=$2} END{printf " threads=%d\n", t}' <(ps -eo pid,nlwp --no-headers 2> /dev/null)
|
||||
|
||||
echo
|
||||
echo "== per-process memory (top 20 by PSS) =="
|
||||
printf ' %-28s %8s %8s %8s\n' COMMAND RSS PSS PRIVATE
|
||||
python3 - "$OUT" <<'PY'
|
||||
import os, sys
|
||||
rows = []
|
||||
for pid in filter(str.isdigit, os.listdir('/proc')):
|
||||
try:
|
||||
cmd = open(f'/proc/{pid}/cmdline', 'rb').read().replace(b'\x00', b' ').decode(errors='replace').strip()
|
||||
if not cmd:
|
||||
continue
|
||||
rss = pss = priv = 0
|
||||
for line in open(f'/proc/{pid}/smaps_rollup'):
|
||||
k, _, v = line.partition(':')
|
||||
v = v.split()[0] if v.split() else '0'
|
||||
if k == 'Rss': rss = int(v)
|
||||
elif k == 'Pss': pss = int(v)
|
||||
elif k in ('Private_Dirty', 'Private_Clean'): priv += int(v)
|
||||
except Exception:
|
||||
continue
|
||||
rows.append((pss, rss, priv, pid, cmd))
|
||||
rows.sort(reverse=True)
|
||||
tr = tp = tv = 0
|
||||
for pss, rss, priv, pid, cmd in rows:
|
||||
tr += rss; tp += pss; tv += priv
|
||||
for pss, rss, priv, pid, cmd in rows[:20]:
|
||||
name = (cmd[:26] + '..') if len(cmd) > 28 else cmd
|
||||
print(f" {name:<28} {rss/1024:7.0f}M {pss/1024:7.0f}M {priv/1024:7.0f}M")
|
||||
print(f"\n {'TOTAL':<28} {tr/1024:7.0f}M {tp/1024:7.0f}M {tv/1024:7.0f}M")
|
||||
print(" ^ quote PRIVATE when claiming what removing a process would free.")
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "== reserved-but-not-resident (lazy allocations, NOT leaks) =="
|
||||
ipcs -m 2>/dev/null | awk 'NR>3 && $5 ~ /^[0-9]+$/ && $5 > 50000000 {printf " SysV shm %.0f MB (owner %s) — check Rss in /proc/<pid>/smaps before calling it used\n", $5/1048576, $3}'
|
||||
|
||||
echo
|
||||
echo "== CPU over ${SAMPLE}s (idle unless you are driving the UI) =="
|
||||
# utime+stime. Parsed after the LAST ')' because field 2 is (comm) and may contain spaces —
|
||||
# a plain $14+$15 is wrong for anything like 'npm exec @foo' and silently reports a fabricated
|
||||
# number rather than failing.
|
||||
jiffies() { awk -F') ' '{n=split($NF,a," "); print a[12]+a[13]}' "/proc/$1/stat" 2>/dev/null; }
|
||||
|
||||
# jiffies are USER_HZ units — almost always 100, but read it rather than assume it
|
||||
HZ=$(getconf CLK_TCK 2>/dev/null) && [ "$HZ" -gt 0 ] 2>/dev/null || HZ=100
|
||||
|
||||
# Sample EVERY readable process, not the top-N of ps.txt: that list is sorted by RSS,
|
||||
# and the busiest process is not necessarily a big one.
|
||||
declare -A t0
|
||||
for d in /proc/[0-9]*; do
|
||||
pid=${d#/proc/}
|
||||
[ -r "$d/stat" ] && t0[$pid]=$(jiffies "$pid")
|
||||
done
|
||||
sleep "$SAMPLE"
|
||||
for pid in "${!t0[@]}"; do
|
||||
[ -r "/proc/$pid/stat" ] || continue
|
||||
t1=$(jiffies "$pid") || continue
|
||||
[ -n "$t1" ] && [ -n "${t0[$pid]}" ] || continue
|
||||
delta=$(( t1 - ${t0[$pid]} ))
|
||||
[ "$delta" -gt 0 ] || continue
|
||||
pct=$(awk -v d="$delta" -v s="$SAMPLE" -v hz="$HZ" 'BEGIN{printf "%.2f", d*100/(hz*s)}')
|
||||
comm=$(tr -d '\0' < "/proc/$pid/comm" 2>/dev/null)
|
||||
echo "$pct $pid $comm"
|
||||
done | sort -rn | head -12 | awk '{printf " %6s%% %-8s %s\n", $1, $2, $3}'
|
||||
|
||||
echo
|
||||
echo " established conns on :8082/:3000/:3001 = $(ss -tn 2>/dev/null | grep -cE 'ESTAB.*:(8082|3000|3001)')"
|
||||
echo " (those are claude_desktop/webtop viewer ports; 0 here means CPU above is idle burn)"
|
||||
echo " raw ps: $OUT/ps.txt"
|
||||
112
.claude/skills/hassio-addon-workflow/scripts/pr_review.sh
Executable file
112
.claude/skills/hassio-addon-workflow/scripts/pr_review.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# Work through bot review comments on a PR. Resolving a thread needs the GraphQL API (the REST
|
||||
# API cannot do it), which is the only reason this script exists.
|
||||
#
|
||||
# pr_review.sh list <PR> every inline comment, grouped
|
||||
# pr_review.sh status <PR> checks + unresolved thread count
|
||||
# pr_review.sh reply <PR> <COMMENT_ID> <text|@file>
|
||||
# pr_review.sh resolve <PR> <THREAD_ID...|--all> --all = every unresolved, asks first
|
||||
# pr_review.sh watch <PR> [minutes] poll checks (run this backgrounded)
|
||||
#
|
||||
# Reviewers seen here: coderabbitai (deepest; reviews ~9 min after open, or on
|
||||
# "@coderabbitai review"), chatgpt-codex-connector, Copilot, Codacy.
|
||||
#
|
||||
# Verify every claim before agreeing. Bots are frequently right and occasionally confidently
|
||||
# wrong; a reproduction takes a minute and decides it either way. Push back with evidence when
|
||||
# you are right — a resolved-but-wrong thread is worse than an open one.
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${HASSIO_REPO:-}"
|
||||
[ -z "$REPO" ] && REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2> /dev/null)
|
||||
[ -z "$REPO" ] && { echo "cannot determine repo; set HASSIO_REPO=owner/name" >&2; exit 1; }
|
||||
echo "repo: $REPO" >&2
|
||||
CMD="${1:-}"; PR="${2:-}"
|
||||
[ -z "$CMD" ] || [ -z "$PR" ] && { sed -n '2,16p' "$0" | sed 's/^# \?//'; exit 1; }
|
||||
|
||||
case "$CMD" in
|
||||
list)
|
||||
echo "== inline comments on #$PR =="
|
||||
gh api "repos/$REPO/pulls/$PR/comments" --paginate \
|
||||
--jq 'sort_by(.created_at)[] | "=== [\(.id)] \(.user.login) | \(.path):\(.line // .original_line) ===\n\(.body)\n"'
|
||||
echo "== review bodies =="
|
||||
gh api "repos/$REPO/pulls/$PR/reviews" \
|
||||
--jq '.[] | select(.body != "") | "--- \(.user.login) (\(.state)) ---\n\(.body[0:4000])\n"'
|
||||
;;
|
||||
status)
|
||||
gh pr checks "$PR" 2>&1 | head -15
|
||||
echo
|
||||
gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:50){nodes{id isResolved path comments(first:1){nodes{author{login}}}}}}}}" \
|
||||
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | "\(if .isResolved then "resolved" else "OPEN " end) \(.id) \(.comments.nodes[0].author.login) \(.path)"'
|
||||
;;
|
||||
reply)
|
||||
ID="${3:?comment id}"; BODY="${4:?text or @file}"
|
||||
if [ "${BODY#@}" != "$BODY" ]; then
|
||||
out=$(gh api "repos/$REPO/pulls/$PR/comments/$ID/replies" -F body=@"${BODY#@}" --jq '.id' 2>&1)
|
||||
rc=$?
|
||||
else
|
||||
out=$(gh api "repos/$REPO/pulls/$PR/comments/$ID/replies" -f body="$BODY" --jq '.id' 2>&1)
|
||||
rc=$?
|
||||
fi
|
||||
# Silently "succeeding" here is worse than failing: a later session reads the transcript and
|
||||
# believes a reviewer was answered when they were not.
|
||||
if [ "$rc" -eq 0 ] && [ -n "$out" ]; then
|
||||
echo "replied to $ID (comment $out)"
|
||||
else
|
||||
echo "FAILED to reply to $ID: $out" >&2
|
||||
echo " (top-level review bodies have different ids and cannot take replies here)" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
resolve)
|
||||
shift 2
|
||||
ids="$*"
|
||||
if [ "${ids:-}" = "--all" ]; then
|
||||
echo "About to resolve EVERY unresolved thread. Only do this if you have read and"
|
||||
echo "answered each one — a resolved-but-wrong thread is worse than an open one."
|
||||
gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:100){nodes{isResolved path comments(first:1){nodes{author{login} body}}}}}}}" \
|
||||
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | " - \(.comments.nodes[0].author.login) \(.path): \(.comments.nodes[0].body[0:90])"'
|
||||
printf 'Type "yes" to resolve all: '; read -r ok
|
||||
[ "$ok" = "yes" ] || { echo "aborted"; exit 1; }
|
||||
ids=""
|
||||
elif [ -z "$ids" ]; then
|
||||
echo "usage: pr_review.sh resolve <PR> <THREAD_ID...> (or --all, with confirmation)" >&2
|
||||
echo "resolve each thread as you answer it; get ids from: pr_review.sh status $PR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ids" ]; then
|
||||
ids=$(gh api graphql -f query="{repository(owner:\"${REPO%%/*}\",name:\"${REPO##*/}\"){pullRequest(number:$PR){reviewThreads(first:50){nodes{id isResolved}}}}}" \
|
||||
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | .id')
|
||||
fi
|
||||
[ -z "$ids" ] && { echo "nothing unresolved"; exit 0; }
|
||||
rfail=0
|
||||
for id in $ids; do
|
||||
r=$(gh api graphql -f query="mutation{resolveReviewThread(input:{threadId:\"$id\"}){thread{isResolved}}}" \
|
||||
--jq '.data.resolveReviewThread.thread.isResolved' 2>&1)
|
||||
echo " $id -> $r"
|
||||
[ "$r" = "true" ] || rfail=1
|
||||
done
|
||||
# exiting 0 on a failed mutation would let a session believe threads were resolved
|
||||
exit "$rfail"
|
||||
;;
|
||||
watch)
|
||||
MINS="${3:-180}" # the addon build alone has taken ~3h; 20 was far too short
|
||||
for i in $(seq 1 "$MINS"); do
|
||||
c=$(gh pr checks "$PR" 2> /dev/null | awk '{print $1"="$2}' | tr '\n' ' ')
|
||||
if [ -z "$c" ]; then
|
||||
# Normal in the first minutes after `gh pr create`, and also whenever gh errors.
|
||||
# Calling that "settled" would report success for checks that never ran.
|
||||
echo "[$i] no checks reported yet (gh returned nothing) — still waiting"
|
||||
sleep 60; continue
|
||||
fi
|
||||
echo "[$i] $c"
|
||||
case "$c" in
|
||||
*pending*) sleep 60 ;;
|
||||
*fail* | *error* | *cancel*) echo "settled — with FAILURES (see above)"; wfail=1; break ;;
|
||||
*) echo "settled — all passing"; break ;;
|
||||
esac
|
||||
done
|
||||
echo "note: long queues here are usually account runner contention, not your diff."
|
||||
exit "${wfail:-0}"
|
||||
;;
|
||||
*) echo "unknown: $CMD"; exit 1 ;;
|
||||
esac
|
||||
87
.claude/skills/hassio-addon-workflow/scripts/preflight.sh
Executable file
87
.claude/skills/hassio-addon-workflow/scripts/preflight.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orient before starting add-on work: what tools exist, are we inside the running add-on, and
|
||||
# — the one that actually bites — does the checkout match what is running?
|
||||
#
|
||||
# Usage: preflight.sh [repo-path] [addon-slug]
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${1:-/data/claude/hassio-addons}"
|
||||
SLUG="${2:-}"
|
||||
|
||||
echo "== tools =="
|
||||
for c in gh git codex rtk headroom tokensave shellcheck hadolint yamllint python3 jq; do
|
||||
printf ' %-11s %s\n' "$c" "$(command -v "$c" > /dev/null 2>&1 && echo yes || echo MISSING)"
|
||||
done
|
||||
[ -x /data/codex/bin/codex-real ] && echo " codex-real yes (prefer: codex exec --model gpt-5.6-sol)"
|
||||
|
||||
echo
|
||||
echo "== running add-on =="
|
||||
if [ -n "${BUILD_VERSION:-}" ]; then
|
||||
echo " BUILD_VERSION=$BUILD_VERSION HOME=${HOME:-?}"
|
||||
echo " -> live measurement is possible; see measure.sh"
|
||||
else
|
||||
echo " not inside a running add-on (no BUILD_VERSION); source-only analysis"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== repo =="
|
||||
# git-aware check: in a worktree .git is a file, not a directory
|
||||
if ! git -C "$REPO" rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo " no git repo at $REPO"
|
||||
exit 0
|
||||
fi
|
||||
cd "$REPO" || exit 0
|
||||
branch=$(git branch --show-current 2> /dev/null || echo "(detached)")
|
||||
echo " path=$REPO"
|
||||
echo " branch=$branch"
|
||||
|
||||
# Another session may be mid-operation in this shared checkout.
|
||||
echo " recent reflog (entries you did not make mean another session is active):"
|
||||
git reflog --date=iso -3 2> /dev/null | sed 's/^/ /'
|
||||
|
||||
# The trap this exists for: a stale branch looks entirely normal.
|
||||
if [ -n "${BUILD_VERSION:-}" ]; then
|
||||
# Hostname is <8-hex>-<slug-with-dashes>. Anchor the hex to 8 chars: an unanchored
|
||||
# [0-9a-f]* also eats real prefixes (dab-radio -> radio, cafe-monitor -> monitor).
|
||||
# Slugs may legitimately contain dashes (birdnet-go), so try both forms.
|
||||
if [ -z "$SLUG" ] && [ -n "${HOSTNAME:-}" ]; then
|
||||
base=$(printf '%s' "$HOSTNAME" | sed 's/^[0-9a-f]\{8\}-//')
|
||||
for cand in "$(printf '%s' "$base" | tr '-' '_')" "$base"; do
|
||||
[ -f "$REPO/$cand/config.yaml" ] && { SLUG="$cand"; break; }
|
||||
done
|
||||
[ -z "$SLUG" ] && SLUG="$base"
|
||||
fi
|
||||
cfg="$REPO/$SLUG/config.yaml"
|
||||
if [ ! -f "$cfg" ]; then
|
||||
echo
|
||||
echo " could not find $SLUG/config.yaml — pass the slug as \$2 to enable the"
|
||||
echo " revision check (this is the check the script exists for)." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [ -f "$cfg" ]; then
|
||||
here=$(grep -E '^version:' "$cfg" | head -1 | tr -d "\"'" | awk '{print $2}')
|
||||
echo
|
||||
echo " $SLUG/config.yaml version = $here"
|
||||
echo " running image BUILD_VERSION = $BUILD_VERSION"
|
||||
if [ "$here" = "$BUILD_VERSION" ]; then
|
||||
echo " MATCH — this checkout corresponds to the running image."
|
||||
else
|
||||
echo " MISMATCH — this branch is NOT what is running."
|
||||
git fetch origin master --quiet 2> /dev/null
|
||||
master=$(git show origin/master:"$SLUG/config.yaml" 2> /dev/null |
|
||||
grep -E '^version:' | head -1 | tr -d "\"'" | awk '{print $2}')
|
||||
echo " origin/master version = ${master:-unknown}"
|
||||
echo " -> work from origin/master; analysing this branch will mislead you."
|
||||
echo
|
||||
echo "== suggested isolated worktree (/tmp is noexec; use /data) =="
|
||||
echo " git worktree add --detach /data/claude/.work/<task> origin/master"
|
||||
echo " NOTE: never 'git stash' under /data/claude — refs/stash is shared."
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== suggested isolated worktree (/tmp is noexec; use /data) =="
|
||||
echo " git worktree add --detach /data/claude/.work/<task> origin/master"
|
||||
echo " NOTE: never 'git stash' under /data/claude — refs/stash is shared across worktrees."
|
||||
128
.claude/skills/hassio-addon-workflow/scripts/validate.sh
Executable file
128
.claude/skills/hassio-addon-workflow/scripts/validate.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run every linter that CI will run and that works locally. The Docker build is deliberately not
|
||||
# attempted: dockerd does not start in this environment, so CI is the only gate for it — say that
|
||||
# rather than implying the build was checked.
|
||||
#
|
||||
# --vs-master re-lints each changed file at origin/master and prints only findings your diff
|
||||
# ADDED. Without it you will chase warnings that were already in the file.
|
||||
#
|
||||
# Usage: validate.sh [addon-dir] [--vs-master]
|
||||
set -uo pipefail
|
||||
|
||||
ADDON="${1:-}"
|
||||
[ "${ADDON:-}" = "--vs-master" ] && { ADDON=""; set -- --vs-master; }
|
||||
VS_MASTER=false
|
||||
for a in "$@"; do [ "$a" = "--vs-master" ] && VS_MASTER=true; done
|
||||
|
||||
if [ -z "$ADDON" ]; then
|
||||
mapfile -t _dirs < <(git diff --name-only origin/master...HEAD 2> /dev/null |
|
||||
cut -d/ -f1 | sort -u | grep -vE '^\.' )
|
||||
if [ "${#_dirs[@]}" -gt 1 ]; then
|
||||
echo "several changed dirs: ${_dirs[*]}"
|
||||
echo "pass one explicitly: validate.sh <addon-dir>"; exit 1
|
||||
fi
|
||||
ADDON="${_dirs[0]:-}"
|
||||
fi
|
||||
[ -z "$ADDON" ] && { echo "usage: validate.sh <addon-dir> [--vs-master]"; exit 1; }
|
||||
git rev-parse --verify origin/master > /dev/null 2>&1 || {
|
||||
echo "origin/master missing — run: git fetch origin master"; exit 1; }
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
echo "== validating $ADDON =="
|
||||
|
||||
fail=0
|
||||
note() { printf ' %-13s %s\n' "$1" "$2"; }
|
||||
|
||||
# Shell: bash -n then shellcheck -x (follows sourced files, as CI does).
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! out=$(bash -n "$f" 2>&1); then note "bash -n" "FAIL $f"; echo "$out" | sed 's/^/ /'; fail=1; fi
|
||||
done < <(find "$ADDON" -type f \( -name '*.sh' -o -name 'run' -o -name 'finish' -o -name 'autostart' \) 2> /dev/null)
|
||||
[ "$fail" -eq 0 ] && note "bash -n" "ok"
|
||||
|
||||
if command -v shellcheck > /dev/null 2>&1; then
|
||||
sc=$(find "$ADDON" -type f \( -name '*.sh' -o -name 'autostart' -o -name 'run' -o -name 'finish' \) -print0 2> /dev/null |
|
||||
xargs -0 -r shellcheck -x -f gcc 2>&1)
|
||||
if [ -n "$sc" ]; then
|
||||
note "shellcheck" "$(printf '%s\n' "$sc" | grep -c .) finding(s)"
|
||||
printf '%s\n' "$sc" | sed 's/^/ /' | head -20
|
||||
else note "shellcheck" "clean"; fi
|
||||
fi
|
||||
|
||||
command -v hadolint > /dev/null 2>&1 && [ -f "$ADDON/Dockerfile" ] && {
|
||||
hl=$(hadolint "$ADDON/Dockerfile" 2>&1)
|
||||
[ -n "$hl" ] && { note "hadolint" "$(printf '%s\n' "$hl" | grep -c .) finding(s)"; printf '%s\n' "$hl" | sed 's/^/ /' | head -10; } || note "hadolint" "clean"
|
||||
}
|
||||
|
||||
if [ -f "$ADDON/config.yaml" ]; then
|
||||
# path passed as argv, never interpolated into Python source
|
||||
python3 - "$ADDON/config.yaml" <<'PY' || { note "config.yaml" "FAIL parse"; fail=1; }
|
||||
import yaml,sys
|
||||
d=yaml.safe_load(open(sys.argv[1]))
|
||||
print(' %-13s ok (version=%s, %d options)' % ('config.yaml', d.get('version'), len(d.get('options') or {})))
|
||||
missing=[k for k in (d.get('options') or {}) if k not in (d.get('schema') or {})]
|
||||
if missing: print(' %-13s options with no schema entry: %s' % ('WARN', missing)); sys.exit(0)
|
||||
PY
|
||||
command -v yamllint > /dev/null 2>&1 && {
|
||||
yl=$(yamllint -f parsable "$ADDON/config.yaml" 2>&1 | grep -c .)
|
||||
note "yamllint" "$yl finding(s) (compare with --vs-master)"
|
||||
}
|
||||
fi
|
||||
|
||||
while IFS= read -r f; do
|
||||
python3 -m py_compile "$f" 2> /dev/null || { note "py_compile" "FAIL $f"; fail=1; }
|
||||
done < <(find "$ADDON" -type f -name '*.py' 2> /dev/null)
|
||||
|
||||
$VS_MASTER && command -v npx > /dev/null 2>&1 && [ -f "$ADDON/CHANGELOG.md" ] && {
|
||||
md=$(npx --yes markdownlint-cli2 "$ADDON/CHANGELOG.md" 2>&1 | grep -cE "CHANGELOG.md:[0-9]+")
|
||||
note "markdownlint" "$md finding(s) in CHANGELOG (mostly pre-existing; lint is continue-on-error in CI)"
|
||||
}
|
||||
|
||||
echo
|
||||
echo "== CI requirements =="
|
||||
if git diff --name-only origin/master...HEAD 2> /dev/null | grep -q "$ADDON/CHANGELOG.md"; then
|
||||
note "CHANGELOG" "updated"
|
||||
else
|
||||
# This one IS gated: onpr_check-pr.yaml exits 1 without it.
|
||||
note "CHANGELOG" "NOT UPDATED — this is the one CI hard-gate"; fail=1
|
||||
fi
|
||||
if git diff origin/master...HEAD -- "$ADDON/config.yaml" 2> /dev/null | grep -q '^+version:'; then
|
||||
note "version" "bumped"
|
||||
else
|
||||
# Repo convention and required for the rebuild to be offered — but no workflow gates it,
|
||||
# so this is a warning, not a failure.
|
||||
note "version" "NOT bumped (convention; no rebuild will be offered) — not a CI gate"
|
||||
fi
|
||||
note "docker build" "NOT tested locally (dockerd unavailable) — CI is the only gate"
|
||||
|
||||
if $VS_MASTER; then
|
||||
echo
|
||||
echo "== findings ADDED by this diff (pre-existing ones filtered out) =="
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
git diff --name-only origin/master...HEAD -- "$ADDON" 2> /dev/null | while IFS= read -r f; do
|
||||
git show "origin/master:$f" > "$tmp/base" 2> /dev/null || continue
|
||||
# A missing linter must be a visible skip, not a silent "no new findings":
|
||||
# its "command not found" error is identical for base and head, so comm would
|
||||
# cancel it out and report a false clean.
|
||||
case "$f" in
|
||||
*.sh | *autostart | */run | */finish)
|
||||
command -v shellcheck > /dev/null 2>&1 || { echo " $f: SKIPPED (shellcheck not installed)"; continue; }
|
||||
cmd() { shellcheck -x -f gcc "$1" 2>&1 | sed 's/^[^:]*:[0-9]*:[0-9]*://'; } ;;
|
||||
*.yaml | *.yml)
|
||||
command -v yamllint > /dev/null 2>&1 || { echo " $f: SKIPPED (yamllint not installed)"; continue; }
|
||||
cmd() { yamllint -f parsable "$1" 2>&1 | sed 's/^[^:]*//; s/^:[0-9]*:[0-9]*//'; } ;;
|
||||
*Dockerfile)
|
||||
command -v hadolint > /dev/null 2>&1 || { echo " $f: SKIPPED (hadolint not installed)"; continue; }
|
||||
cmd() { hadolint "$1" 2>&1 | sed 's/^[^:]*//; s/^:[0-9]*//'; } ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
cp "$tmp/base" "$tmp/base_f"; b=$(cmd "$tmp/base_f" | sort)
|
||||
a=$(cmd "$f" | sort)
|
||||
new=$(comm -13 <(printf '%s\n' "$b") <(printf '%s\n' "$a") | grep -c .)
|
||||
[ "$new" -gt 0 ] && { echo " $f: $new NEW finding(s)"; comm -13 <(printf '%s\n' "$b") <(printf '%s\n' "$a") | sed 's/^/ /' | head -5; }
|
||||
done
|
||||
echo " (nothing listed above = your diff introduced no new lint findings)"
|
||||
fi
|
||||
|
||||
echo
|
||||
[ "$fail" -eq 0 ] && echo "== local validation passed ==" || echo "== local validation FAILED =="
|
||||
exit "$fail"
|
||||
Reference in New Issue
Block a user