From 0de15ca93a4fa345be1bc4afc4346fc977523198 Mon Sep 17 00:00:00 2001 From: yolossn Date: Sun, 14 Jun 2026 15:34:33 +0530 Subject: [PATCH] headlamp-plugin: add opt-in --with-claude-skills to create/upgrade Scaffolds a Claude Code agent harness (CLAUDE.md, .claude/skills, .claude/settings.json, .mcp.json) when the flag is passed; default scaffold path is unchanged. CLAUDE.md replaces AGENTS.md when present. Signed-off-by: yolossn --- .claude/skills/pr-triage/SKILL.md | 127 ++++++++ plugins/headlamp-plugin/README.md | 33 ++ .../headlamp-plugin/bin/headlamp-plugin.js | 81 ++++- plugins/headlamp-plugin/package.json | 1 + .../template-claude/.claude/settings.json | 42 +++ .../.claude/skills/add-detail-view/SKILL.md | 300 ++++++++++++++++++ .../.claude/skills/add-list-view/SKILL.md | 260 +++++++++++++++ .../.claude/skills/add-settings/SKILL.md | 119 +++++++ .../.claude/skills/create-crd-plugin/SKILL.md | 137 ++++++++ .../.claude/skills/define-resource/SKILL.md | 85 +++++ .../.claude/skills/document-plugin/SKILL.md | 107 +++++++ .../.claude/skills/ensure-dependency/SKILL.md | 65 ++++ .../.claude/skills/plan-plugin/SKILL.md | 170 ++++++++++ .../.claude/skills/run-and-verify/SKILL.md | 89 ++++++ .../.claude/skills/seed-test-data/SKILL.md | 90 ++++++ .../headlamp-plugin/template-claude/.mcp.json | 22 ++ .../headlamp-plugin/template-claude/CLAUDE.md | 120 +++++++ .../headlamp-plugin/test-headlamp-plugin.js | 61 +++- 18 files changed, 1904 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/pr-triage/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/settings.json create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/add-detail-view/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/add-list-view/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/add-settings/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/create-crd-plugin/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/define-resource/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/document-plugin/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/ensure-dependency/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/plan-plugin/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/run-and-verify/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.claude/skills/seed-test-data/SKILL.md create mode 100644 plugins/headlamp-plugin/template-claude/.mcp.json create mode 100644 plugins/headlamp-plugin/template-claude/CLAUDE.md diff --git a/.claude/skills/pr-triage/SKILL.md b/.claude/skills/pr-triage/SKILL.md new file mode 100644 index 00000000000..719866a668b --- /dev/null +++ b/.claude/skills/pr-triage/SKILL.md @@ -0,0 +1,127 @@ +--- +name: pr-triage +description: Use this skill when the user asks to "triage PRs", "check new PRs", "review open PRs", "find PRs missing copilot review", or otherwise wants a sweep of open pull requests on the current GitHub repository to surface ones missing a Copilot review request and ones whose commits do not follow the Linux kernel-style format used in this repo. +--- + +# PR Triage + +## Purpose + +Sweep open PRs on the current repo and produce a short, actionable report: + +1. **PRs missing a Copilot review request** — flag so the user can request one. +2. **PRs whose commits do not follow Linux kernel-style** — flag and draft a ready-to-paste reply. + +Output is read by the user to decide what to do next. Do not request reviews, push commits, or post comments without explicit instruction. + +## Step 1 — Resolve the repo + +Run from the current working directory: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +``` + +If this errors, stop and tell the user the directory is not a GitHub repo (or `gh` is not authenticated). + +## Step 2 — List candidate PRs (open, not draft, no human reviews yet) + +```bash +gh pr list \ + --search "is:open is:pr draft:false review:none" \ + --json number,title,author,headRefName,url \ + --limit 50 +``` + +Notes: +- `review:none` excludes PRs that already have any **human** review. (A Copilot review does **not** count as a human review for this filter, so PRs Copilot has reviewed but no human has are still in scope — that is intentional.) +- `draft:false` excludes drafts; mention this in the output. +- If the list is empty, report "no open PRs needing triage" and stop. + +## Step 3 — Detect missing Copilot review request + +**Do not parse `reviewRequests` JSON for this.** Copilot does **not** appear in the `reviewRequests` field returned by `gh pr view --json reviewRequests` — neither as a User nor as a Bot. The only reliable signal is GitHub's search filters. + +Run a second search that returns the candidate PRs **missing** Copilot involvement (neither requested nor already reviewed by Copilot): + +```bash +gh pr list \ + --search "is:open is:pr draft:false review:none -review-requested:app/copilot-pull-request-reviewer -reviewed-by:app/copilot-pull-request-reviewer" \ + --json number \ + --limit 50 +``` + +Any PR number in this result is **missing Copilot**. PR numbers in Step 2's list but **not** in this list have Copilot involved (either requested or already reviewed). + +The reviewer slug is `app/copilot-pull-request-reviewer`. If your org uses a different Copilot app login, change the slug — but verify by running: + +```bash +gh pr list --search "is:open is:pr reviewed-by:app/copilot-pull-request-reviewer" --json number --limit 3 +``` + +If that returns nothing for a repo you know Copilot has reviewed, the slug is wrong for this org. + +## Step 4 — Check commits for Linux kernel-style + +For each PR, fetch its commits: + +```bash +gh pr view --json commits -q '.commits[].messageHeadline' +``` + +A commit **passes** if its subject line matches the convention used in this repo's `git log`: + +- Format: `[: ]*: ` +- Areas seen in this repo: `frontend`, `backend`, `app`, `docs`, `chocolatey`, `ci`, plus nested forms like `backend: server:` or `frontend: common/ReleaseNotes/ReleaseNotes:`. +- Subject ≤ ~75 characters, no trailing period, imperative mood (`Add`, `Fix`, `Bump`, `Refactor`, not `Added`/`Adding`/`Fixes`). +- Conventional-commit prefixes (`feat:`, `fix:`, `chore:`, `feat(scope):`) are **not** this repo's style — flag them. +- Lowercase-only or punctuation-only subjects (`update stuff`, `wip`, `.`) are flagged. + +If unsure about borderline cases, sanity-check against recent history: + +```bash +git log --no-merges --format='%s' -30 +``` + +A PR **fails** the check if **any** of its commits fail. Record which commits failed and why (one short reason each). + +## Step 5 — Report + +Print a single compact report. Use this shape: + +``` +## PR triage — open PR(s) without a review (drafts excluded) + +### ✅ Clean +- #1234 — Title (author) — + +### ⚠️ Missing Copilot review +- #1235 — Title (author) — +- #1236 — Title (author) — + +### ⚠️ Commit messages need cleanup +- #1237 — Title (author) — + - "feat: add thing" — uses Conventional-commit prefix; expected `: ` + - "wip" — non-descriptive +- #1238 — Title (author) — + - "fixed bug" — past tense; expected imperative ("Fix …") + +PRs in both ⚠️ sections appear in both. +``` + +For each PR in **Commit messages need cleanup**, also include a ready-to-paste reply block. Use the user's preferred wording verbatim — do not paraphrase: + +``` +**Reply for #1237:** +> The commit messages just need a quick cleanup to match our Linux kernel–style guidelines. The contributing guide and git log both have good examples. +``` + +If there are zero issues across both checks, just say so in one line. + +## Boundaries + +- **Read-only.** Do not run `gh pr review`, `gh pr comment`, `gh pr edit`, or any mutating gh command. The user posts replies themselves. +- **Do not** request Copilot as a reviewer automatically. +- **Do not** suggest force-pushing or amending commits on the user's behalf. +- If `gh` is not installed or not authenticated, say so and stop. +- Cap at 50 PRs per run; if there are more, mention it and ask whether to paginate. diff --git a/plugins/headlamp-plugin/README.md b/plugins/headlamp-plugin/README.md index 6718bbf5bd8..423f4f8643b 100644 --- a/plugins/headlamp-plugin/README.md +++ b/plugins/headlamp-plugin/README.md @@ -66,6 +66,39 @@ headlamp-plugin --help headlamp-plugin.js uninstall [pluginName] Uninstall the plugin. ``` +## Scaffolding the Claude Code agent harness + +`create` accepts an opt-in `--with-claude-skills` flag: + +``` +headlamp-plugin create my-plugin --with-claude-skills +``` + +In addition to the default scaffold, this adds a Claude Code agent harness for +building the plugin with an AI agent: + +- `CLAUDE.md` — always-on agent policy for a Headlamp plugin (replaces the + default `AGENTS.md`). +- `.claude/skills/` — step-by-step skills for the CNCF/CRD plugin workflow + (`create-crd-plugin`, `plan-plugin`, `define-resource`, `add-list-view`, + `add-detail-view`, `add-settings`, `ensure-dependency`, `seed-test-data`, + `run-and-verify`, `document-plugin`). +- `.claude/settings.json` — a permission allowlist for the common dev commands. +- `.mcp.json` — the `kubernetes`, `helm` and `chrome-devtools` MCP servers the + skills use. + +Without the flag, `create` behaves exactly as before (default `AGENTS.md`, no +`.claude/` or `.mcp.json`). + +To add the harness to an **existing** plugin, pass the same flag to `upgrade`: + +``` +headlamp-plugin upgrade --with-claude-skills +``` + +Existing harness files are left untouched, so it is safe to re-run; it only adds +what is missing and drops `AGENTS.md` once `CLAUDE.md` is present. + ## Template for installing plugins from a configuration file plugins.yaml: diff --git a/plugins/headlamp-plugin/bin/headlamp-plugin.js b/plugins/headlamp-plugin/bin/headlamp-plugin.js index 5508c802ffc..983f809dd19 100755 --- a/plugins/headlamp-plugin/bin/headlamp-plugin.js +++ b/plugins/headlamp-plugin/bin/headlamp-plugin.js @@ -47,12 +47,40 @@ const vitePromise = import('vite'); * Copies the files within template, and modifies a couple. * Then runs "npm ci" inside of the folder. * +/** + * Adds the opt-in Claude Code agent harness to a plugin folder. + * + * Copies CLAUDE.md, .claude/ (skills + settings) and .mcp.json from the + * "template-claude" folder into dstFolder, and drops AGENTS.md since CLAUDE.md + * supersedes it as the single agent guide. Existing files are left untouched + * (overwrite: false), so it is safe to run against an already-scaffolded plugin. + * + * @param {string} dstFolder - plugin folder to add the harness to. + */ +function addClaudeHarness(dstFolder) { + const claudeTemplateFolder = path.resolve(__dirname, '..', 'template-claude'); + console.log('Adding Claude Code agent skills (CLAUDE.md, .claude/, .mcp.json)'); + fs.copySync(claudeTemplateFolder, dstFolder, { + overwrite: false, + errorOnExist: false, + }); + const agentsPath = path.join(dstFolder, 'AGENTS.md'); + if (fs.existsSync(agentsPath)) { + fs.removeSync(agentsPath); + } +} + +/** * @param {string} name - name of package and output folder. * @param {boolean} link - if we link @kinvolk/headlamp-plugin for testing * @param {boolean} noInstall - if we skip installing with "npm ci" + * @param {boolean} withClaudeSkills - if we also scaffold the Claude Code agent + * harness (CLAUDE.md, .claude/skills, .claude/settings.json, .mcp.json) from + * the "template-claude" folder. When set, the default template's AGENTS.md is + * dropped in favour of CLAUDE.md. * @returns {0 | 1 | 2 | 3} Exit code, where 0 is success, 1, 2, and 3 are failures. */ -function create(name, link, noInstall) { +function create(name, link, noInstall, withClaudeSkills) { const dstFolder = name; const templateFolder = path.resolve(__dirname, '..', 'template'); const indexPath = path.join(dstFolder, 'src', 'index.tsx'); @@ -98,6 +126,13 @@ function create(name, link, noInstall) { replaceFileVariables(indexPath); replaceFileVariables(readmePath); + // Opt-in Claude Code agent harness. Copied from a separate "template-claude" + // folder so the default scaffold stays untouched unless --with-claude-skills + // is passed. + if (withClaudeSkills) { + addClaudeHarness(dstFolder); + } + // This can be used to make testing locally easier. if (link) { console.log('Linking @kinvolk/headlamp-plugin'); @@ -945,9 +980,12 @@ function getNpmOutdated() { * @param packageFolder {string} - folder where the package, or folder of packages is. * @parm skipPackageUpdates {boolean} - do not upgrade packages if true. * @param headlampPluginVersion {string} - tag or version of headlamp-plugin to upgrade to. + * @param withClaudeSkills {boolean} - if true, add the Claude Code agent harness + * (CLAUDE.md, .claude/, .mcp.json) to the package(s) being upgraded. Existing + * harness files are left untouched; AGENTS.md is dropped in favour of CLAUDE.md. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ -function upgrade(packageFolder, skipPackageUpdates, headlampPluginVersion) { +function upgrade(packageFolder, skipPackageUpdates, headlampPluginVersion, withClaudeSkills) { /** * Files from the template might not be there. * @@ -965,6 +1003,17 @@ function upgrade(packageFolder, skipPackageUpdates, headlampPluginVersion) { 'tsconfig.json', 'AGENTS.md', ]; + + // Plugins scaffolded with `create --with-claude-skills` use CLAUDE.md as the + // single agent guide instead of AGENTS.md, so don't reintroduce AGENTS.md on + // upgrade for them. + if (fs.existsSync('CLAUDE.md')) { + const agentsIndex = missingFiles.indexOf('AGENTS.md'); + if (agentsIndex !== -1) { + missingFiles.splice(agentsIndex, 1); + } + } + const templateFolder = path.resolve(__dirname, '..', 'template'); missingFiles.forEach(pathToCheck => { @@ -1194,6 +1243,12 @@ function upgrade(packageFolder, skipPackageUpdates, headlampPluginVersion) { process.chdir(folder); console.log(`Upgrading "${folder}"...`); + // Opt-in: add the Claude Code harness. Run before addMissingTemplateFiles so + // that CLAUDE.md exists and AGENTS.md is not (re)added for harness plugins. + if (withClaudeSkills) { + addClaudeHarness('.'); + } + addMissingTemplateFiles(); addMissingConfiguration(); removeFiles(); @@ -1756,11 +1811,17 @@ yargs(process.argv.slice(2)) .option('noinstall', { describe: 'Skip installing dependencies with npm ci', type: 'boolean', + }) + .option('with-claude-skills', { + describe: + 'Also scaffold the Claude Code agent harness (CLAUDE.md, .claude/skills, ' + + '.claude/settings.json, .mcp.json). Replaces the default AGENTS.md with CLAUDE.md.', + type: 'boolean', }); }, argv => { // @ts-ignore - process.exitCode = create(argv.name, argv.link, argv.noinstall); + process.exitCode = create(argv.name, argv.link, argv.noinstall, argv['with-claude-skills']); } ) .command( @@ -1978,11 +2039,23 @@ yargs(process.argv.slice(2)) describe: 'Use a specific headlamp-plugin-version when upgrading packages. Defaults to "latest".', type: 'string', + }) + .option('with-claude-skills', { + describe: + 'Add the Claude Code agent harness (CLAUDE.md, .claude/skills, ' + + '.claude/settings.json, .mcp.json) to the upgraded plugin(s). Replaces AGENTS.md ' + + 'with CLAUDE.md. Existing harness files are left untouched.', + type: 'boolean', }); }, argv => { // @ts-ignore - process.exitCode = upgrade(argv.package, argv.skipPackageUpdates, argv.headlampPluginVersion); + process.exitCode = upgrade( + argv.package, + argv.skipPackageUpdates, + argv.headlampPluginVersion, + argv['with-claude-skills'] + ); } ) .command( diff --git a/plugins/headlamp-plugin/package.json b/plugins/headlamp-plugin/package.json index 8c4b319e1a8..f215f30d447 100644 --- a/plugins/headlamp-plugin/package.json +++ b/plugins/headlamp-plugin/package.json @@ -134,6 +134,7 @@ "bin", "config", "template", + "template-claude", "lib", "types", ".storybook", diff --git a/plugins/headlamp-plugin/template-claude/.claude/settings.json b/plugins/headlamp-plugin/template-claude/.claude/settings.json new file mode 100644 index 00000000000..2374ba6c192 --- /dev/null +++ b/plugins/headlamp-plugin/template-claude/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Skill", + "Bash(npm install:*)", + "Bash(npm start:*)", + "Bash(npm run tsc:*)", + "Bash(npm run lint:*)", + "Bash(npm run lint-fix:*)", + "Bash(npm run build:*)", + "Bash(npm run format:*)", + "Bash(npm run test:*)", + "Bash(npm run i18n:*)", + "Bash(npm run package:*)", + "Bash(npx @kinvolk/headlamp-plugin:*)", + "Bash(npx tsc:*)", + "Read(.claude/**)", + "Read(node_modules/@kinvolk/headlamp-plugin/**)", + "Read(/tmp/**)", + "Bash(echo:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "Bash(grep:*)", + "Bash(find:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(which:*)", + "Bash(sleep:*)", + "Bash(mkdir:*)", + "Bash(cp:*)", + "Bash(kubectl get:*)", + "Bash(kubectl describe:*)", + "Bash(kubectl explain:*)", + "Bash(kubectl wait:*)", + "mcp__kubernetes__*", + "mcp__chrome-devtools__*" + ], + "ask": [], + "deny": [] + } +} diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/add-detail-view/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/add-detail-view/SKILL.md new file mode 100644 index 00000000000..ee99e123a98 --- /dev/null +++ b/plugins/headlamp-plugin/template-claude/.claude/skills/add-detail-view/SKILL.md @@ -0,0 +1,300 @@ +--- +name: add-detail-view +description: Build this plugin's own resource detail page with kubectl-describe parity — render every meaningful spec/status field via DetailsGrid (extraInfo + per-group extraSections), a Conditions section placed LAST so it sits directly above the auto Events section, related/owner links, and a read-only Monaco CodeBlock for free-form blobs. Consumes the typed class from /define-resource; pairs with /add-list-view. Works standalone (fix/extend an existing detail page). +allowed-tools: + - mcp__kubernetes__* + - Bash(kubectl get:*) + - Bash(kubectl describe:*) + - Write(src/**) + - Edit(src/**) + - Edit(PLAN.md) + - Bash(npm run tsc:*) + - Bash(npm run lint:*) + - Bash(npm run lint-fix:*) + - Bash(npm run build:*) +--- + +# add-detail-view + +A list answers "which objects exist"; the **detail page** answers "what is this one object, fully" — +it must be as complete as `kubectl describe `. `DetailsGrid` renders default metadata for +free, but **everything from `spec`/`status` is yours to render**. The classic failure is a page with +two or three hand-picked `extraInfo` rows: it builds green, loads fine, and silently omits most of +the object. + +**Prerequisites:** the typed class (`/define-resource`) and the list + `:name` detail route +(`/add-list-view`). Build the detail component in `src/components//Detail.tsx`. + +## The describe-parity rule + +Render every meaningful field, sourced from the real object — not a guessed subset: + +1. **Enumerate the shape from the live cluster** — a real instance + the CRD's `openAPIV3Schema` + (`kubectl explain .spec` / the CRD). That schema *is* the field checklist; cross-check + `kubectl describe `. +2. **Map each field to a place on the page** (layout below). Deliberately omitting a field (huge + blobs, internal, redundant) is fine — a decision, not an oversight; note skips in `PLAN.md`. +3. **Read through typed getters** (`obj.componentType`), not `jsonData?.spec?.…?`. If a field needs + a clean read, add a getter in `/define-resource`'s class. + +**Wrap the Detail body in the not-installed gate too** — `` +(the shared `src/components/common/NotInstalled.tsx` from `/add-list-view`) — a stale deep-link can +hit this page on a cluster without the CRD. + +## Page layout (DetailsGrid order is fixed — exploit it) + +`DetailsGrid` always renders: **back link → header → main info → `extraSections` → Events** (Events +only with `withEvents`). So: + +| Region | What | How | +|---|---|---| +| **Back link** | Returns to the list | **auto-derived** from the resource's list route — **don't pass `backLink`** | +| **Main info** (top) | Identity + headline scalars (type, version, ready, key spec summary) | `extraInfo={item => NameValueTableRow[]}` | +| **Header actions** | **Edit / Delete / Scale / Restart come FREE by default**; only add *project-specific* ones | `actions={…}` (see "Project-specific actions" below) | +| **`extraSections`** (middle) | One `SectionBox` per logical `spec`/`status` group — the bulk of parity | `extraSections={item => DetailsViewSection[]}` | +| **Conditions** | `status.conditions` — **LAST in `extraSections`** | `ConditionsSection` (auto-wraps) | +| **Events** (bottom) | Object events | `withEvents` (auto-rendered after `extraSections`) | + +**Back link is free** — DetailsGrid derives it from the resource's list route, so **omit `backLink`** +(passing a bare boolean is a type error; the prop is `string | location | null`). + +**Conditions go LAST** so they render directly above the auto Events section — the two +"what's happening now" panels read together, matching the bottom of `kubectl describe`. Use the +higher-level **`ConditionsSection`** (keda's pattern — it wraps the conditions table in its own +section; less boilerplate than a manual `SectionBox` + `ConditionsTable`). (If `status` has no +`conditions`, skip it but surface whatever status signal exists — `phase`/`observedGeneration` — in +main info or a Status section.) + +## Putting it together + +```tsx +// src/components/components/Detail.tsx (Dapr Component example) +import { ConditionsSection, DetailsGrid, NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { useParams } from 'react-router-dom'; +import { Component } from '../../resources/component'; +import { CrdInstalledGate } from '../common/NotInstalled'; +import { ReadyStatusLabel } from '../common/ReadyStatusLabel'; + +export function ComponentDetail() { + const { namespace, name } = useParams<{ namespace: string; name: string }>(); + const { t } = useTranslation(); + return ( + + [ // headline scalars (labels = plain English) + { name: 'Type', value: item.componentType }, + { name: 'Version', value: item.version }, + { name: 'Ready', value: }, + ]} + extraSections={item => [ // the BULK of parity — one section per group + { id: 'my-plugin-config', section: ( + + ({ name: m.name, value: m.value ?? '—' }))} /> + ) }, + // …a section for every meaningful spec/status group… + { id: 'my-plugin-conditions', section: }, // ← LAST: sits just above Events + ]} + /> + + ); +} +``` + +- `extraInfo`/`NameValueTable` `value` can be a React node — render a `` for an owner ref or a + referenced Secret/ConfigMap, a status chip, a chip. +- **Status/health scalars use the shared `ReadyStatusLabel`** (condition-backed: severity color + + reason/message tooltip) — the *same* component the list uses, so both render identically. For a + simple enum/phase with no reason/message, a bare `{phase}` + is fine. +- Nested/list data → `NameValueTable` (key/value) or a small `Table`, in a titled `SectionBox`. + **Never dump raw JSON as `
`/`JSON.stringify`.** When an array's items each own a *sub-array*,
+  don't flatten — nest the table (see "Arrays of grouped objects" below).
+- **Owner & related objects → `Link`s** (target Deployment/HPA, Secret, ConfigMap, another CR) —
+  the same relationships you draw as Map edges. This makes the page navigable like built-in pages.
+
+## Arrays of grouped objects — nest the table, don't flatten the key
+
+Many CRDs carry a **list whose every item itself owns a sub-list** of structured rows — a parent
+that *groups* children. Kueue's `status.flavorsUsage[]` is the canonical case: each flavor has a
+`resources[]` array of `{ name, total, borrowed }`. The tempting move is to **flatten** the two
+levels into one flat table (one row per flavor × resource). Don't — flattening **repeats the parent
+identity on every child row**, so `default-flavor` shows up once per resource and the column reads
+like noise (the Kueue "Flavor Usage" / "Flavor Reservation" symptom):
+
+```
+Flavor          Resource   Total   Borrowed     ← flattened: parent key repeats, hard to scan
+default-flavor  cpu        1       0
+default-flavor  memory     256Mi   0
+```
+
+**Rule: when an array's items each contain a sub-array, render a two-column outer table — column 1
+is the parent's identity, column 2 is a nested table of that parent's children — instead of
+flattening the parent key into every child row.** The parent appears exactly once; its children
+read as a self-contained block beside it. This is the describe-parity-faithful shape for grouped
+data (it mirrors how `kubectl describe` indents a group under its heading), and it scales when a
+parent has many children or several would-be-repeated columns.
+
+```
+Flavor                                                       ← nested: parent once, children grouped
+default-flavor   ┌──────────┬───────┬──────────┐
+                 │ Resource │ Total │ Borrowed │
+                 ├──────────┼───────┼──────────┤
+                 │ cpu      │ 1     │ 0        │
+                 │ memory   │ 256Mi │ 0        │
+                 └──────────┴───────┴──────────┘
+```
+
+### How: a child `SimpleTable` inside the parent column's `getter`
+
+No new component is needed — nest a `SimpleTable` directly inside the section. A `SimpleTable`
+column has **no `render` prop**; the `getter`'s return value is rendered straight into the cell, so
+it can be any React node — including another `SimpleTable`. So the parent column's `getter` returns
+the child table. **What generalizes is this shape, not the labels** — for your resource, swap the
+two column headers, the parent's `getter`, the `getChildren` accessor, and the child columns for
+your own fields:
+
+```tsx
+import { SimpleTable } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+
+// `groups` is the array whose items each own a sub-array — e.g. Kueue's status.flavorsUsage[].
+// Replace the labels / getters / child columns with YOUR resource's fields:
+ g.name,                           // parent identity, shown ONCE per group
+    },
+    {
+      label: 'Resources',                            // ← nested column header (your field)
+      getter: g => (                                 // getter returns the child table
+         c.name },
+            { label: 'Total', getter: c => String(c.total ?? '-') },
+            { label: 'Borrowed', getter: c => String(c.borrowed ?? '-') },
+          ]}
+        />
+      ),
+    },
+  ]}
+/>
+```
+
+Notes:
+- `getter` returns the cell node — there is no separate `render`. Use `cellProps` (spread onto the
+  MUI `TableCell`) for per-cell styling like `verticalAlign`, and `gridTemplate` to keep the parent
+  column narrow so the nested table gets the width.
+- **Pick the nested component by the child's shape:**
+  - **Child is a *list of records*** (each child has the same handful of fields, and there are
+    several) → nest a **`SimpleTable`** (columns = fields, rows = children), as above. This is the
+    "list of lists" case — Kueue's flavor → `resources[]`.
+  - **Child is a *single object*** (one record's named key/value attributes, not a repeated row) →
+    nest a **`NameValueTable`** instead — a one-row `SimpleTable` is the wrong shape for "this
+    object's fields". Same parent column, just swap the `getter`'s return:
+    ```tsx
+    import { NameValueTable } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+    // parent column 2:
+    getter: g => (
+      
+    ),
+    ```
+- Use this only for genuine parent→children grouping. A flat list of scalar rows stays a flat
+  `NameValueTable`/`SimpleTable`; don't nest a table that has nothing to group.
+- **Don't reach for a generic wrapper component** ("GroupedTable" etc.) — the inline nested
+  `SimpleTable` is easier to read and is the whole pattern. Only if *one plugin* repeats this for
+  several fields and the duplication actually bothers you, extract a tiny local helper in that
+  plugin's `src/components/common/`; it's never required by this skill. (Kueue does this once as
+  `FlavorUsageTable`/`QuotaTable` — but each is just the inline pattern above.)
+
+## Raw YAML — the whole object is FREE; inline `CodeBlock` only for a specific field
+
+**Whole-object YAML/edit is already there** — DetailsGrid's default `EditButton` (see header actions)
+opens the object's YAML editor. **Don't hand-roll a "View YAML" button** for the whole object; just
+don't set `noDefaultActions` (set it only when you deliberately want a read-only page).
+
+Use an inline **read-only Monaco `CodeBlock`** only for a **specific free-form field** that's part of
+the object and worth showing *in context* — an embedded YAML/template (e.g. Tinkerbell
+`Template.spec.data`), a script, an inline manifest, a Helm `values` blob, a PEM/cert. Wrap it once as
+`src/components/common/CodeBlock.tsx`. Monaco is externalized (`@monaco-editor/react` →
+`pluginLib.ReactMonacoEditor`), so the value import is runtime-safe.
+
+```tsx
+// src/components/common/CodeBlock.tsx
+import { Editor } from '@monaco-editor/react';                  // → pluginLib.ReactMonacoEditor (runtime-safe)
+import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { CopyButton } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+import Box from '@mui/material/Box';
+import { useTheme } from '@mui/material/styles';
+
+export default function CodeBlock({ value, language = 'plaintext', ariaLabel, height }:
+  { value: string; language?: string; ariaLabel?: string; height?: number | string }) {
+  const theme = useTheme();
+  const { t } = useTranslation();
+  const lines = value ? value.split('\n').length : 1;
+  const computed = height ?? Math.min(Math.max(lines, 3), 30) * 19 + 16;
+  return (
+    
+      
+      
+    
+  );
+}
+```
+
+Use it as ``. Structured data still uses `NameValueTable`/`Table`. Monaco
+only proves out at runtime — verify on the live load.
+
+## Project-specific actions (mutations beyond the free defaults)
+
+Edit / Delete / Scale / Restart are added by DetailsGrid **for free**. Add a custom header action
+only for a **project-specific operation** that `/plan-plugin` decided this CRD needs (flux
+suspend/resume, keda pause, a "trigger reconcile") — those are context-dependent, so the plan names
+them per CRD. A custom action is a **confirm-gated** header action that mutates via
+`ApiProxy.request` (the only write path — the cluster MCP is read-only):
+
+```tsx
+import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
+import { ActionButton } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+// in  [ ... ]} >:
+ ApiProxy.request(
+    `/apis/dapr.io/v1alpha1/namespaces/${item.metadata.namespace}/components/${item.metadata.name}`,
+    { method: 'PATCH', body: JSON.stringify({ spec: { suspend: true } }),
+      headers: { 'Content-Type': 'application/merge-patch+json' } })}
+/>
+```
+
+Always confirm before a mutation, give the icon an accessible name (`description`), and surface
+success/failure via the `Notification` API. (If a plugin grows several of these, factor them into
+`src/components//actions.tsx`.)
+
+## i18n + a11y, then gate + verify
+
+Wrap **prose** (section titles, descriptions, empty/error text) in `t(...)`; short field/condition
+labels stay plain English. Icon-only actions need an accessible name; keep `npm run lint` green.
+
+`npm run tsc && npm run lint && npm run build`, then **`/run-and-verify`**: open a real instance's
+detail (click a Name link), confirm **no `Plugin execution error`**, the **spec/status sections are
+present and populated** (not metadata + two rows), the **Conditions table renders directly above
+Events**, labels show **real text** (not blank — i18n resolution), and compare against
+`kubectl describe  ` — anything material it shows and the page doesn't is a gap (or a
+noted skip). Record the rendered section set in `PLAN.md`.
+
+**North star:** KEDA's `src/components/scaledobjects/Detail.tsx`.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/add-list-view/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/add-list-view/SKILL.md
new file mode 100644
index 00000000000..ab18ee13546
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/add-list-view/SKILL.md
@@ -0,0 +1,260 @@
+---
+name: add-list-view
+description: Add a CRD's resource-view backbone to this plugin — the list page (ResourceListView with kubectl-matching columns), the list+detail routes and sidebar entry, the shared not-installed banner, and the Map (topology) source. Consumes the typed class from /define-resource. Works standalone (add a new CRD's view to an existing plugin) or as a build step after /plan-plugin. Pairs with /add-detail-view for the detail page.
+allowed-tools:
+  - mcp__kubernetes__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl describe:*)
+  - Write(src/**)
+  - Edit(src/**)
+  - Edit(PLAN.md)
+  - Bash(npm run tsc:*)
+  - Bash(npm run lint:*)
+  - Bash(npm run lint-fix:*)
+  - Bash(npm run build:*)
+---
+
+# add-list-view
+
+Builds the **backbone** for one CRD: its typed class, list page, routes, the not-installed gate, and
+its Map source. The detail page itself is `/add-detail-view`. Model everything from the **live
+cluster**, never guessed shapes. If a `PLAN.md` exists, take the column set / grouping from it.
+
+## Prerequisite — the typed resource class
+
+This view consumes the typed `src/resources/.ts` class (`extends KubeObject` +
+typed getters). **Create it first with `/define-resource`** — the examples below use its `Widget`
+class (`widget.size`, `widget.readyStatus`, …). If it doesn't exist yet, stop and run
+`/define-resource` for this CRD, then come back.
+
+## 1) List page — `src/components//List.tsx`
+
+Use **`ResourceListView`** (full page: title + filter header + table; auto-fetches via
+`resourceClass`). **Columns are curated for value** (planned in `/plan-plugin` §3), not a mechanical
+copy of kubectl's printer columns: keep the `kubectl get` defaults visible (the floor an operator
+expects), **promote** high-value wide/`additionalPrinterColumns`/schema fields to visible (status,
+target, key counts), **hide** low-value extras with `show: false` (toggleable), and **add** valuable
+`spec`/`status` columns that no printer column exposes. Every column's *value* is sourced from a real
+field — never a fabricated value.
+
+```tsx
+import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+import { Widget } from '../../resources/widget';
+import { CrdInstalledGate } from '../common/NotInstalled';
+import { ReadyStatusLabel } from '../common/ReadyStatusLabel';   // shared status wrapper (see below)
+
+export function WidgetList() {
+  const { t } = useTranslation();
+  return (
+               {/* install banner on clusters without the CRD */}
+       i.size },          // labels: plain English (CLAUDE.md)
+          { id: 'ready', label: 'Ready',
+            getValue: i => i.readyStatus,                                 // raw string → sort / filter / export
+            render: i =>  },
+          'age',
+          { id: 'target', label: 'Target', getValue: i => i.spec.target.name, show: false }, // -o wide extra
+        ]}
+      />
+    
+  );
+}
+```
+
+`columns` accept built-in `ColumnType` (`'name'`,`'namespace'`,`'age'`,`'kind'`,`'type'`,`'labels'`)
+plus custom `{ id, label, getValue, render?, show? }`. For non-resource tabular data use `Table`
+(material-react-table), not `ResourceListView`.
+
+> **Multi-cluster is automatic for lists** — `ResourceListView`/`useList` follow the active cluster
+> group and auto-add a `'cluster'` column when more than one is selected. Don't hand-roll
+> cluster-spanning logic for the list.
+
+**Status/health rendering (contextual):**
+- **Condition-backed status** (carries a `reason`/`message`) → a small shared wrapper
+  `src/components/common/ReadyStatusLabel.tsx`: `StatusLabel` (severity color + text) **inside a
+  `LightTooltip`** that shows the reason/message — so an operator sees *why* it's failing on hover
+  (knative/kubeflow pattern). Reused identically by list columns and detail.
+- **Simple enum/phase status** (no reason/message) → a bare
+  `{phase}` is fine.
+- Either way, a status column provides **both** `render` (the chip) **and** `getValue` (the raw
+  string, so sort/filter/export work). Keep one **status→severity** mapper near the typed class /
+  `common.ts` so list and detail agree.
+
+```tsx
+// src/components/common/ReadyStatusLabel.tsx — condition-backed status with reason/message tooltip
+import { LightTooltip, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import Box from '@mui/material/Box';
+
+export function ReadyStatusLabel({ status, reason, message }: { status: string; reason?: string; message?: string }) {
+  const { t } = useTranslation();
+  const sev = status === 'True' ? 'success' : status === 'False' ? 'error' : 'warning';
+  const label = status === 'True' ? t('Ready') : status === 'False' ? t('Not Ready') : t('Unknown');
+  const tip = [reason && `${t('Reason')}: ${reason}`, message && `${t('Message')}: ${message}`].filter(Boolean).join('\n');
+  const chip = {label};
+  return tip ? {tip}}>{chip} : chip;
+}
+```
+
+## 2) Routes + sidebar — `src/index.tsx` (registration only)
+
+**⚠ Namespace EVERYTHING you register under the project name.** Route `path`s, route `name`s, and
+sidebar entry `name`s all live in a **single global namespace shared with Headlamp core and every
+other installed plugin** — an unprefixed `/workloads` path or a `name: 'workloads'` sidebar entry
+*will* collide (silently shadowing core/another plugin, or being shadowed). So:
+- route `path` → **`//…`** (e.g. `/my-plugin/widgets`)
+- sidebar entry `name` and route `name` → **`-…`** (e.g. `my-plugin-widgets`, `my-plugin-widget`)
+- table `id` → already `-…` (§1). Use `package.json` `name` as the `` token.
+
+**Multi-CRD plugins: drive registration from a config array + a `registerResource()` loop** (the
+keda/cluster-api/kubeflow pattern) — DRY, consistent, and prefixes everything in one place:
+
+```tsx
+const PROJECT = 'my-plugin';   // === package.json name
+const RESOURCES = [
+  { name: 'Widgets', plural: 'widgets', List: WidgetList, Detail: WidgetDetail, namespaced: true },
+  // …one entry per CRD…
+];
+function registerResource(c) {
+  const entry = `${PROJECT}-${c.plural}`;          // global-unique sidebar/route id (prefixed)
+  registerSidebarEntry({ parent: PROJECT, name: entry, label: c.name, url: `/${PROJECT}/${c.plural}` });
+  registerRoute({ path: `/${PROJECT}/${c.plural}`, sidebar: entry, exact: true, component: c.List });
+  registerRoute({
+    path: `/${PROJECT}/${c.namespaced ? ':namespace/' : ''}${c.plural}/:name`,
+    sidebar: entry, name: `${PROJECT}-${c.plural.slice(0, -1)}`, component: c.Detail,  // prefixed route name
+  });
+}
+registerSidebarEntry({ name: PROJECT, label: 'My Plugin', url: `/${PROJECT}/${RESOURCES[0].plural}` }); // parent (name IS the project → unique)
+RESOURCES.forEach(registerResource);
+```
+
+A **single-resource** plugin can inline the two `registerRoute` + one `registerSidebarEntry` calls —
+still prefixed. **Sidebar landing:** the parent entry carries its own `url`; for a *small* plugin point
+it at the primary list, for a *many-CRD* plugin at a dedicated **Overview** page (see `/plan-plugin`
+§Navigation). Sidebar/route labels are module-scope, so keep them plain English (the `t()` hook can't
+run there). A Name column links to the detail via the **prefixed** route `name`:
+
+```tsx
+{ id: 'name', label: 'Name', getValue: w => w.metadata.name,
+  render: w => {w.metadata.name} }
+```
+Route `path`s omit `/c/`; URLs you *construct* include it (`getCluster()`).
+
+## 3) Map (topology) source — `src/mapView.tsx`
+
+CRDs belong in the Map, **grouped under ONE project parent** (don't scatter N top-level entries).
+One leaf `GraphSource` per CRD → one parent → `registerMapSource(parent)` once.
+
+```tsx
+import { Icon } from '@iconify/react';
+import { registerKindIcon, registerMapSource } from '@kinvolk/headlamp-plugin/lib';
+import { useMemo } from 'react';
+import { Widget } from './resources/widget';
+import { WidgetDetail } from './components/widgets/Detail';
+
+// Every CNCF plugin defines this edge helper locally (no shared lib helper):
+const makeKubeToKubeEdge = (from: any, to: any, opts: any = {}) => ({
+  id: `${from.metadata.uid}-${to.metadata.uid}`, source: from.metadata.uid, target: to.metadata.uid, ...opts,
+});
+
+const widgetSource = {
+  id: 'my-plugin-widgets', label: 'Widgets',
+  icon: ,   // REQUIRED — see the icon trap
+  useData() {
+    const [widgets] = Widget.useList();
+    return useMemo(() => {
+      if (!widgets) return null;
+      const nodes = widgets.map(w => ({
+        id: w.metadata.uid,          // node id = the object's uid (always)
+        kubeObject: w,
+        weight: 1000,                // optional: higher = laid out closer to the root
+        detailsComponent: ({ node }) => ,
+      }));
+      const edges = []; // for each CR, push makeKubeToKubeEdge(w, relatedObject) to link it to its Deployment/Secret/…
+      return { nodes, edges };
+    }, [widgets]);
+  },
+};
+export const myPluginSource = {                                   // ONE parent groups every leaf
+  id: 'my-plugin', label: 'My Plugin',
+  icon: ,
+  sources: [widgetSource /*, … */],
+};
+// index.tsx:
+registerMapSource(myPluginSource);                               // ONE call
+registerKindIcon('Widget', { icon: , color: 'rgb(50,108,229)' });
+```
+
+**The icon trap — two independent surfaces, both silent:** `GraphSource.icon` (on **every** leaf
+*and* the parent) icons the **Map source-filter/legend**; `registerKindIcon` icons the graph
+**nodes**. Doing only `registerKindIcon` leaves filter rows blank. And every `icon` must be a
+**rendered element** (``), never the bare `'mdi:…'` string — `icon` is typed
+`ReactNode` *and optional*, so tsc catches neither a missing icon nor a string (it renders as
+literal text). Only the live Map view proves icons render.
+
+## 4) Gate + verify (live load is non-skippable)
+
+`npm run tsc && npm run lint && npm run build`, then `/run-and-verify`: the console has **no
+`Plugin execution error`**, the sidebar entry/route/list renders (`take_snapshot`), the Name link
+opens the detail, the CRs show in the **Map with real icons** on both filter rows and nodes. A
+value-import/`extends` mistake passes the build but crashes at load — only this catches it. Then
+build the detail page with **`/add-detail-view`**.
+
+## Reference: the not-installed gate — `src/components/common/NotInstalled.tsx`
+
+One reusable wrapper for every List and Detail. Detects CRD **presence** (so *installed-but-empty*
+still shows the normal empty state) and, when absent, shows a banner with a docs link.
+
+```tsx
+import { Icon } from '@iconify/react';
+import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { Loader, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
+import Alert from '@mui/material/Alert';
+import AlertTitle from '@mui/material/AlertTitle';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import { PROJECT } from '../../resources/common'; // { name, docsUrl, installCmd }
+
+const CRD = K8s.ResourceClasses.CustomResourceDefinition;
+
+export function useCrdInstalled(crdName: string): 'loading' | 'present' | 'absent' {
+  const [crd, error] = CRD.useGet(crdName);                 // crdName = '.'
+  if (error) return 'absent';                               // cluster-api style: any get error ⇒ not-installed
+  return crd ? 'present' : 'loading';                       // distinguishes not-installed from installed-but-empty
+}
+
+export function CrdInstalledGate({ crd, children }: { crd: string; children: React.ReactNode }) {
+  const { t } = useTranslation();
+  const state = useCrdInstalled(crd);
+  if (state === 'loading') return ;
+  if (state === 'present') return <>{children};
+  return (
+    
+      }>
+        {t('{{project}} is not installed in this cluster', { project: PROJECT.name })}
+        {t('This view needs the {{crd}} CRD, which is absent on the selected cluster. Install {{project}}, then reload.', { crd, project: PROJECT.name })}
+        {PROJECT.installCmd && (
+          {PROJECT.installCmd}
+        )}
+        
+          
+        
+      
+    
+  );
+}
+```
+
+Wrap **both** List and Detail bodies in `` (this is the same
+`CustomResourceDefinition.useGet` detection cluster-api uses). Verify the banner by loading on a
+cluster **without** the CRD.
+
+**North star:** KEDA — https://github.com/headlamp-k8s/plugins/tree/main/keda (`src/resources/*.ts`,
+`src/components//List.tsx`, the `kedaSource` Map parent).
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/add-settings/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/add-settings/SKILL.md
new file mode 100644
index 00000000000..0c54591df26
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/add-settings/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: add-settings
+description: Add a settings page to this plugin backed by ConfigStore — a typed src/config.ts module, a settings UI registered with registerPluginSettings, and reactive reads of the saved config in components. Settings can be plugin-wide or per-cluster. Use when the plugin needs user-configurable, persisted options (refetch interval, feature toggles, default namespace, …). Build it BEFORE any view that reads a setting — the config module is their dependency. Works standalone.
+allowed-tools:
+  - Write(src/**)
+  - Edit(src/**)
+  - Edit(PLAN.md)
+  - Bash(npm run tsc:*)
+  - Bash(npm run lint:*)
+  - Bash(npm run lint-fix:*)
+  - Bash(npm run build:*)
+---
+
+# add-settings
+
+A Settings page (under Headlamp Settings → Plugins) whose values persist and can be read reactively.
+The store is `ConfigStore` — typed, persisted to browser local storage, **per browser profile (not
+synced)**. The store key and the `registerPluginSettings` name **must equal `package.json` `name`**.
+
+**Build order:** if any view reads a setting, build this `src/config.ts` module **before** those
+views (it's their dependency) — `/plan-plugin` records which views consume which settings.
+
+## 1) Typed config — `src/config.ts`
+
+```ts
+import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
+
+export interface MyPluginConfig { showDetails: boolean }
+export const DEFAULT_CONFIG: MyPluginConfig = { showDetails: true };
+
+export const store = new ConfigStore('my-plugin');   // key === package.json name
+
+export function getConfig(): MyPluginConfig {
+  return { ...DEFAULT_CONFIG, ...store.get() };                      // get() is undefined before first save
+}
+```
+
+## Scope — plugin-wide vs per-cluster
+
+The example above is **plugin-wide**: one flat object, the same on every cluster. When a setting must
+differ per cluster (a cluster-specific metrics URL, a per-cluster default namespace), make it
+**per-cluster** by keying the config by cluster name and reading the current cluster's slice with
+`getCluster()`:
+
+```ts
+import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils';
+
+export interface PerClusterConfig { metricsUrl?: string; defaultNamespace?: string }
+export interface MyPluginConfig {
+  showDetails: boolean;                          // plugin-wide
+  clusters: Record;    // per-cluster, keyed by cluster name
+}
+export const DEFAULT_CONFIG: MyPluginConfig = { showDetails: true, clusters: {} };
+export const DEFAULT_PER_CLUSTER: PerClusterConfig = { defaultNamespace: 'default' };
+
+// read the current cluster's slice (merged with defaults):
+export function getClusterConfig(): PerClusterConfig {
+  const cluster = getCluster() ?? '';
+  return { ...DEFAULT_PER_CLUSTER, ...getConfig().clusters[cluster] };
+}
+```
+
+In the settings UI, let the user pick a cluster (`K8s.useClustersConf()`) and edit that cluster's
+slice; write back with `store.update({ clusters: { ...cfg.clusters, [cluster]: next } })`. Mixing both
+in one config is fine — flat keys for plugin-wide, the `clusters` map for per-cluster. Record each
+setting's scope in `PLAN.md`.
+
+## 2) Settings UI — `src/settings.tsx`
+
+`registerPluginSettings(name, Component, true)` injects `data` + `onDataChange` and shows a Save
+button (pass `false` to save yourself).
+
+```tsx
+import { PluginSettingsDetailsProps, useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import Switch from '@mui/material/Switch';
+import { DEFAULT_CONFIG } from './config';
+
+export default function Settings({ data, onDataChange }: PluginSettingsDetailsProps) {
+  const { t } = useTranslation();
+  const config = { ...DEFAULT_CONFIG, ...data };
+  return (
+     onDataChange?.({ ...config, showDetails: e.target.checked })} />} />
+  );
+}
+```
+
+## 3) Register — `src/index.tsx`
+
+```ts
+import { registerPluginSettings } from '@kinvolk/headlamp-plugin/lib';
+import Settings from './settings';
+registerPluginSettings('my-plugin', Settings, true);   // name === package.json name
+```
+
+## 4) Read the config
+
+- **Reactively** (re-renders on change) — call the hook at the **top level**, before any early return:
+  ```tsx
+  import { store, DEFAULT_CONFIG } from './config';
+  const useConf = store.useConfig();
+  const config = { ...DEFAULT_CONFIG, ...useConf() };
+  ```
+- **Once, outside React:** `getConfig()`. **Write:** `store.set(value)` / `store.update(partial)`.
+
+## Rules / gotchas
+
+- Name + store key **must match `package.json` `name`** (case-sensitive).
+- Always spread `{ ...DEFAULT_CONFIG, ...store.get() }` — `get()` is `undefined` before first save.
+- `useConfig()` at the top of the component, never after a conditional return (rules of hooks).
+- Per-browser local storage — **not synced; don't store secrets**.
+- i18n + a11y apply (labels via `t(...)`, proper control labels).
+
+## Gate + verify
+
+`npm run tsc && npm run lint && npm run build`, then `/run-and-verify`: open Settings → Plugins →
+your plugin, toggle a value, Save, confirm the dependent UI reacts with no console errors.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/create-crd-plugin/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/create-crd-plugin/SKILL.md
new file mode 100644
index 00000000000..8e5158c7f9d
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/create-crd-plugin/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: create-crd-plugin
+description: Day-one buildout of this Headlamp plugin into a complete UI for a CNCF/operator project's CRDs (Dapr, KEDA, Flux, cert-manager, Istio, …). Drives the whole flow against a live cluster: identify the project (inferred from this plugin) → pick the cluster → ensure it is installed (helm) → discover ALL its CRDs → seed test data → plan (sidebar/grouping, list columns from `kubectl get -o wide`, detail layout from `kubectl describe`) → build a list + detail view for every CRD (+ settings if needed) → live-test (incl. a11y + i18n) → document. Use to build this plugin out from an empty/near-empty `src/`. To EXTEND an already-built plugin (add one view, fix a field), call the standalone sub-skills directly instead. Run from the main session — it holds the permission gates.
+argument-hint: "[project] — optional; inferred from this plugin (package.json name, the CRDs in src/resources/). Pass only to override."
+allowed-tools:
+  - mcp__kubernetes__*
+  - mcp__helm__install_helm_chart
+  - mcp__chrome-devtools__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl describe:*)
+  - Bash(kubectl explain:*)
+  - Bash(kubectl config get-contexts:*)
+  - Bash(kubectl config current-context:*)
+  - Bash(kubectl config use-context:*)
+  - Bash(kubectl apply -f test-files:*)
+  - Bash(kubectl delete -f test-files:*)
+  - Bash(kubectl create namespace:*)
+  - Bash(helm version:*)
+  - Bash(helm repo:*)
+  - Bash(helm install:*)
+  - Bash(docker build:*)
+  - Bash(kind load:*)
+  - Bash(minikube image load:*)
+  - Bash(npm install)
+  - Bash(npm start)
+  - Bash(npm run tsc)
+  - Bash(npm run lint)
+  - Bash(npm run lint-fix)
+  - Bash(npm run build)
+  - Bash(npm run i18n)
+  - Bash(npm run package)
+  - Bash(curl:*)
+  - Write(src/**)
+  - Edit(src/**)
+  - Write(test-files/**)
+  - Edit(test-files/**)
+  - Write(PLAN.md)
+  - Edit(PLAN.md)
+  - Write(docs/**)
+  - Edit(docs/**)
+  - WebFetch
+  - WebSearch
+---
+
+# create-crd-plugin
+
+The **day-one buildout** loop: turn this scaffolded, near-empty plugin into a complete
+operator-project plugin. A CNCF/CRD plugin has a fixed, heavier shape than a one-off contribution: it
+needs the project **installed**, **real seeded data**, and a **list + detail view for every one of
+the project's CRDs**. This skill sequences those phases and dispatches each to a focused sub-skill.
+
+**Scope — this is the accelerator, not the only door.** Use it once, to go from empty `src/` to a
+full first version. After that, the plugin is *maintained and extended* directly through the
+standalone sub-skills — `/add-list-view` and `/add-detail-view` add a CRD that shipped later,
+`/run-and-verify` re-checks after a Headlamp/toolchain bump, etc. Don't re-run this whole loop to
+make a small change.
+
+You (the main session) run this loop because it stops at **⛔ human-permission gates** — choosing
+the cluster, installing via helm, and applying fixtures all touch the user's cluster.
+
+**Connective artifact: `PLAN.md`** (committed). The plan phase writes it; every build/test phase
+reads it and updates it in place — it is the source of truth *for this initial build* and lets a
+collaborator resume. At the document phase it is distilled into `docs/OVERVIEW.md`, which becomes
+the permanent record. (When extending an *already-built* plugin, there's no from-zero plan: the
+code + `docs/OVERVIEW.md` are the source of truth — update those.)
+
+## Phase 0 — Setup (against a live cluster)
+
+1. **Identify the project.** Infer it from this plugin — the `package.json` `name`/`description`
+   and any CRDs already modeled in `src/resources/`. State your inference and confirm with the user;
+   the optional `[project]` argument overrides. (This plugin declares what it's for — don't ask for
+   what you can read.)
+2. **Pick the cluster. ⛔** List configured clusters via the kubernetes MCP (or
+   `kubectl config get-contexts`). If more than one, **STOP and ask the user which to use**; with a
+   single context, use it and say so. Then **set it once** with `kubectl config use-context ` and
+   run **plain verb-first `kubectl` commands** for the rest of the build (a `--context` flag before the
+   verb breaks the `allowed-tools` match and re-prompts every command).
+3. **Ensure the project is installed. ⛔** Run **`/ensure-dependency`** — detect whether the
+   project's CRDs exist in the chosen cluster; if absent, ask permission and install via the helm
+   MCP. Never install without explicit approval.
+4. **Discover ALL the CRDs.** `kubectl get crds | grep ` (e.g. `dapr.io`) — enumerate the
+   project's full CRD set from the **live cluster**, not a memorized list (you will miss newer ones).
+   This set is the plugin's scope.
+5. **Seed test data. ⛔** Run **`/seed-test-data`** — author sample CRs (from the
+   project's docs, validated against the live CRD schema via `kubectl explain` / the CRD's
+   `openAPIV3Schema`) into `test-files/`, covering the states the UI must show (healthy / failing /
+   paused / …), then `kubectl apply` them (a cluster write — ask first). Without instances the views
+   render empty and can't be verified.
+
+## Phase 1 — Plan
+
+Run **`/plan-plugin`**. This is the tunable core. It decides, and writes to `PLAN.md`:
+
+- **Scope** — every CRD from Phase 0.4 (or an explicit, stated reason one is skipped).
+- **Navigation** — sidebar entries and their **grouping**: ONE parent entry carrying its own
+  landing `url`, a child per CRD, and the route → page mapping.
+- **List views** — per CRD, the columns, sourced from `kubectl get  -o wide` (defaults visible)
+  + the CRD's `additionalPrinterColumns` (extras, hidden/toggleable). Don't invent columns.
+- **Detail views** — per CRD, the layout, sourced from `kubectl describe /`: which
+  `spec`/`status` groups become sections, Conditions last, related/owner links.
+- **Settings** — whether the plugin needs a settings page, and what it controls.
+- **i18n / a11y obligations** — which strings get `t(...)`, which controls need accessible names.
+
+## Phase 2 — Build (reading PLAN.md)
+
+**First, settings — if any view consumes them.** If the plan's Settings section lists a setting that
+a view reads (refetch interval, feature toggle, default namespace, …), run **`/add-settings`** now —
+the `src/config.ts` module is a dependency of those views, so it must exist before them. A settings
+page that nothing reads can wait until last. Build it per the planned scope (plugin-wide or
+per-cluster).
+
+**Then, for each CRD in scope, in order:**
+1. **`/define-resource`** — the typed `src/resources/.ts` class (spec/status interface +
+   `extends KubeObject` + getters). The foundation the rest reads.
+2. **`/add-list-view`** — list page, routes, sidebar entry, not-installed gate, Map source.
+3. **`/add-detail-view`** — the describe-parity detail page.
+
+Wire everything from a thin `src/index.tsx`. Update `PLAN.md`'s **Status** as each step lands
+(settings → model defined → list → detail → verified).
+
+## Phase 3 — Live-test (non-skippable)
+
+Run **`/run-and-verify`**: `npm start`, then via the chrome-devtools MCP confirm the plugin loads
+(no `Plugin execution error`), every contribution renders, **i18n resolves (no blank labels)** and
+the **a11y tree** gives every control a name/role. Gate-green ≠ loads (see CLAUDE.md). Fix and
+reload until clean.
+
+## Phase 4 — Document
+
+Run **`/document-plugin`** — generate `docs/OVERVIEW.md`: a CRD coverage table (List/Detail ✓) and,
+per resource, `kubectl get` beside a list screenshot + `kubectl describe` beside a detail screenshot,
+plus Map screenshots. This distills `PLAN.md` into the permanent record.
+
+## Definition of done
+
+Every discovered CRD has a list + detail view; the plugin loads and is verified live (incl. a11y +
+i18n); `docs/OVERVIEW.md` exists. Reconcile `PLAN.md` so no section contradicts the shipped reality.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/define-resource/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/define-resource/SKILL.md
new file mode 100644
index 00000000000..7161570785c
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/define-resource/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: define-resource
+description: Define a typed custom-resource class for one of the project's CRDs — model its spec/status as a TypeScript interface from the live CRD schema, subclass KubeObject, and add typed getters. This is the foundation the list, detail, Map, and action skills all build on. Use first, per CRD, before /add-list-view and /add-detail-view. Works standalone (add a model for a CRD that shipped later).
+allowed-tools:
+  - mcp__kubernetes__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl explain:*)
+  - Write(src/resources/**)
+  - Edit(src/resources/**)
+  - Edit(PLAN.md)
+---
+
+# define-resource
+
+Every view, Map node, and mutation reads the resource through a **typed class** — so model the CRD
+once, here, and everything downstream reads `obj.componentType` instead of `obj.jsonData?.spec?.…?`
+soup. One file per CRD: `src/resources/.ts`. Model from the **live CRD schema**, never guessed
+shapes.
+
+## The pattern (what every CNCF plugin does)
+
+A typed CRD class is `class X extends KubeObject` with **static** API metadata and typed
+getters. This is the standard across the official CNCF plugins (keda, cert-manager, knative, strimzi,
+volcano, cluster-api, …) on the current lib.
+
+```ts
+// src/resources/component.ts  (Dapr Component example)
+import { KubeObject, KubeObjectInterface } from '@kinvolk/headlamp-plugin/lib/k8s/cluster';
+
+export interface ComponentSpec { type: string; version: string; metadata?: { name: string; value?: string }[] }
+export interface DaprComponent extends KubeObjectInterface {
+  spec: ComponentSpec;
+  status?: { conditions?: { type: string; status: string; reason?: string; message?: string }[] };
+}
+
+export class Component extends KubeObject {
+  static apiVersion = 'dapr.io/v1alpha1';   // or an array for multiple served versions
+  static kind = 'Component';
+  static apiName = 'components';             // the CRD plural
+  static isNamespaced = true;
+
+  get spec() { return this.jsonData.spec; }
+  // computed getters centralize defaults / optional handling:
+  get componentType(): string { return this.spec?.type ?? ''; }
+  get readyStatus(): string {
+    const c = this.jsonData.status?.conditions ?? [];
+    return c.find(x => x.type === 'Ready')?.status ?? 'Unknown';
+  }
+}
+```
+
+The class inherits `.useList()` / `.useGet()` and drops into `resourceClass={Component}`. `KubeObject`
+and `KubeObjectInterface` are both value/type from the externalized `@kinvolk/headlamp-plugin/lib/k8s/cluster`
+(runtime-safe; CLAUDE.md).
+
+## Steps
+
+1. **Read the CRD schema from the cluster** — don't recall it from memory:
+   - `kubectl get crd . -o yaml` → **group, version(s), `scope` (Namespaced?), kind,
+     `names.plural`** (= `apiName`), and `spec.versions[].schema.openAPIV3Schema` (the field shape).
+   - `kubectl explain .spec` / `.status` to sanity-check; read a real seeded instance
+     (`kubectl get / -o yaml`) so optional/absent fields are obvious.
+2. **Write the `spec`/`status` interface** `extends KubeObjectInterface` from that schema — model the
+   real shape, mark optional fields `?`, reuse shared enums/types from `src/resources/common.ts`.
+3. **Subclass `KubeObject`** with the static API metadata + typed getters (above). Add a
+   getter whenever a view needs a clean field — that's what the typed class is for.
+4. **Shared types → `src/resources/common.ts`** — cross-CRD enums/types (e.g. a `Condition` shape),
+   the **status→severity** mapper reused by list + detail, and `PROJECT = { name, docsUrl, installCmd }`
+   (the not-installed banner's source of truth, set by `/ensure-dependency`).
+
+## Notes
+
+- **Multiple served versions:** `static apiVersion = ['x.io/v1', 'x.io/v1beta1']` (cluster-api,
+  strimzi do this). For version-divergent shapes, normalize in getters/helpers.
+- **Cluster-scoped CRDs:** `static isNamespaced = false` and the detail route drops `:namespace`.
+- **Alternative — `makeCustomResourceClass({ apiInfo, kind, pluralName, singularName, isNamespaced })`**
+  from `@kinvolk/headlamp-plugin/lib/Crd` — use it only when you generate many similar classes **from
+  config** (radius, karpenter wrap it in a factory). For a hand-written CRD class, prefer
+  `extends KubeObject` (simpler, and what 9/10 CNCF plugins use).
+- **Verify with a live load** — `extends KubeObject` resolves to the externalized
+  `pluginLib.K8s.cluster.KubeObject` at runtime; a wrong value-import elsewhere still passes the gates
+  and crashes at load, so re-check via `/run-and-verify` whenever you add a new value import or `extends`.
+
+**North star:** keda's `src/resources/*.ts` + `common.ts`, and cluster-api's `src/resources/` for
+multi-version handling — https://github.com/headlamp-k8s/plugins/tree/main/keda
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/document-plugin/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/document-plugin/SKILL.md
new file mode 100644
index 00000000000..a4561c49d4a
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/document-plugin/SKILL.md
@@ -0,0 +1,107 @@
+---
+name: document-plugin
+description: Generate docs/OVERVIEW.md for this built plugin — a CRD coverage table (List/Detail ✓), and per resource the live `kubectl get -o wide` output beside a list-view screenshot and the full `kubectl describe` output beside a detail-view screenshot, Map screenshots, and the settings page (if any) with a per-setting scope/default/effect table. Captures screenshots via the chrome-devtools MCP against the running app. Use after live verification (the final phase), while Headlamp is still up. Distills PLAN.md into the permanent record.
+allowed-tools:
+  - mcp__chrome-devtools__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl describe:*)
+  - Write(docs/**)
+  - Edit(docs/**)
+  - Edit(PLAN.md)
+---
+
+# document-plugin
+
+Produce a single `docs/OVERVIEW.md` that lets a reviewer see — without installing anything — **what
+the plugin covers and what each view actually renders**, by pairing authoritative `kubectl` output
+with a screenshot of the matching view. This is the **permanent record** that `PLAN.md` is distilled
+into (a committed deliverable, not scratch).
+
+## Preconditions
+
+- The plugin is **built and live-verified** (`/run-and-verify`): loads with no `Plugin execution
+  error`, every CRD has a list + detail view, the Map shows the grouped node. Document the working
+  plugin — don't paper over a broken one.
+- **Headlamp is running** with the chrome-devtools MCP attached (screenshots come from the live app).
+- The cluster has the **seeded fixtures** applied (`/seed-test-data`) so views are non-empty and the
+  Map has real edges.
+- Read `PLAN.md` for the full CRD scope (the coverage checklist), routes, and scenario→state matrix.
+
+## Output
+
+```
+docs/
+  OVERVIEW.md
+  img/  list-.png  detail-.png  map-filters.png  map-plugin-only.png  map-graph-.png  settings.png
+```
+Reference images with **relative** paths (`![…](img/list-….png)`).
+
+## Steps
+
+1. **CRD coverage table** — one row per CRD from `PLAN.md` scope:
+
+   | CRD | Description | Notes | List | Detail |
+   |---|---|---|:---:|:---:|
+   | `Component` (`dapr.io/v1alpha1`) | Pluggable building-block config | namespaced | ✓ | ✓ |
+
+   Description/Notes from the CRD (`spec.names`, its description/printer columns) + project docs — not
+   invented. ✓ where the view exists; a *stated* skip gets `—` + a one-line reason. This column is the
+   scope-coverage proof.
+
+2. **Per resource — list view:** paste the **literal `kubectl get  -A -o wide`** output (always
+   `-o wide` → the full column set the list mirrors) in a fenced block, then a **list screenshot**
+   (`navigate_page` to the list route → `take_screenshot` → `img/list-.png`) beneath it.
+
+3. **Per resource — detail view:** pick a representative instance (a meaningful state from the matrix):
+   - **`kubectl describe   -n ` — the COMPLETE output, verbatim**, pasted whole. Don't
+     trim metadata/Labels/Annotations/Events — it's the describe-parity yardstick.
+   - **Full-page detail screenshot.** Detail pages overflow the viewport; `fullPage:true` alone clips
+     because Headlamp's main area is a fixed-height stack with internal scroll. **Before the shot,
+     `evaluate_script` this to unclip the chain, then `take_screenshot` with `fullPage:true`:**
+     ```js
+     let el = document.querySelector('main');
+     while (el && el !== document.documentElement) {
+       el.style.height = 'auto'; el.style.maxHeight = 'none'; el.style.overflow = 'visible';
+       el = el.parentElement;
+     }
+     return document.documentElement.scrollHeight;   // should now exceed the viewport
+     ```
+     Verify the returned height exceeds one viewport; if not, the selector missed the scroll layer —
+     re-inspect and walk again. **Do not use `resize_page`** (Electron lacks the Browser CDP domain).
+
+4. **Map (topology) section** — `navigate_page` to the Map, then capture **all three**:
+   - **`map-filters.png`** — the Map with the **source-filter / legend visible**, showing the
+     plugin's grouped parent node + its per-Kind leaf sources **with their icons** (this screenshot is
+     what proves the icons render — the two-surface icon trap from `/add-list-view`).
+   - **`map-plugin-only.png`** — **toggle the filter so only the plugin's resources are enabled**
+     (disable the built-in / other sources), then screenshot the focused graph.
+   - **`map-graph-.png`** (1–2) — `click` a node that **has edges** (a CR linked to its
+     Deployment/Secret/another CR) so its **connected graph** expands, and screenshot what links to
+     what. Pick the richest relationship(s) so the topology value is visible.
+
+   Add a sentence per screenshot describing the relationship shown. (If a view overflows the viewport,
+   use the unclip-the-chain `evaluate_script` from step 3 before the shot.)
+
+5. **Settings section (only if the plugin has a settings page).** Open Settings → Plugins → your
+   plugin, `take_screenshot` → `img/settings.png`, and build a settings table from `PLAN.md` /
+   `src/config.ts`:
+
+   | Setting | Scope | Default | Controls |
+   |---|---|---|---|
+   | Refetch interval | plugin-wide | 5s | how often the lists poll |
+   | Metrics URL | per-cluster | — | the Prometheus endpoint for this cluster |
+
+   Note the scope (plugin-wide vs per-cluster) and which views each setting affects. Skip this section
+   entirely if the plugin has no settings.
+
+6. **Assemble `OVERVIEW.md`:** title + one-paragraph summary (project, version, cluster, # CRDs) → the
+   coverage table → a `## ` section per CRD (list pair, then detail pair) → `## Map view` →
+   `## Settings` (if any) → a short How-to-run/package footer.
+
+## Guardrails
+
+- Screenshots must be the **real running plugin** against seeded data — never mock-ups. If a view
+  can't be captured, fix that first (it means it isn't verified).
+- `kubectl` output is **literal AND complete** — don't hand-edit values to match the UI or abridge it.
+  A genuine mismatch is a finding (a column/section gap), not something to smooth over.
+- Factual, concise prose — a coverage report, not marketing.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/ensure-dependency/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/ensure-dependency/SKILL.md
new file mode 100644
index 00000000000..902fd158bc2
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/ensure-dependency/SKILL.md
@@ -0,0 +1,65 @@
+---
+name: ensure-dependency
+description: Ensure the CNCF/operator project this plugin targets (Dapr, KEDA, Flux, cert-manager, …) is installed in the chosen cluster — detect its CRDs via the kubernetes MCP, and if missing, ask the user for permission and install via the helm MCP. Use before building or verifying the plugin, whose views render nothing on a cluster without the project's CRDs.
+allowed-tools:
+  - mcp__kubernetes__*
+  - mcp__helm__install_helm_chart
+  - Bash(kubectl get:*)
+  - Bash(kubectl config get-contexts:*)
+  - Bash(kubectl config current-context:*)
+  - Bash(kubectl config use-context:*)
+  - Bash(helm version:*)
+  - Bash(helm repo:*)
+  - Bash(helm install:*)
+  - Write(src/resources/common.ts)
+  - Edit(src/resources/common.ts)
+  - Edit(PLAN.md)
+---
+
+# ensure-dependency
+
+The plugin's views depend on a project's custom resources, so the project must actually be
+installed in the target cluster — otherwise you're building and testing against an empty cluster.
+Never silently proceed or fabricate data.
+
+## Steps
+
+1. **Confirm the target cluster.** `kubectl config get-contexts` (or the kubernetes MCP). If more
+   than one context, **ask the user which to use**. Then **set it as the current context once** —
+   `kubectl config use-context ` — and run **plain verb-first `kubectl` commands** thereafter
+   (`kubectl get …`, not `kubectl --context  get …`). A `--context` flag between `kubectl` and
+   the verb breaks the `allowed-tools` prefix match and re-prompts every command; setting the context
+   once keeps the rest prompt-free.
+
+2. **Detect whether the project is installed** (kubernetes MCP, read-only): list CRDs and look for
+   the project's API group (`dapr.io`, `keda.sh`, `*.toolkit.fluxcd.io`, `cert-manager.io`, …).
+   Absence of its CRDs ⇒ not installed. If present, you're done — go enumerate the CRDs (step 5).
+
+3. **If missing, ASK for explicit permission.** Present: the project, the **chart ref + repo URL**,
+   the **target namespace**, and that it installs the **latest** chart (no version pin) into *their*
+   cluster, and that uninstall is not automated. Don't install without a clear "yes".
+
+4. **On approval, install via the helm MCP** (`install_helm_chart`) — pass `chart`, `repo`, `name`,
+   `namespace`, `createNamespace: true`, and the chosen `context`. Then verify with the kubernetes
+   MCP that the CRDs exist and operator pods reach `Running` (give CRDs a few seconds to register).
+
+5. **Enumerate the COMPLETE CRD set** — `kubectl get crds | grep `. This **live** list (not
+   your memory of the project's "main" CRDs — you'll miss newer ones like Dapr's `mcpservers`) is the
+   plugin's scope.
+
+6. **Record install metadata** in `PLAN.md` (Project & cluster): project, installed version,
+   namespace, the official **install-docs URL**, and the `helm`/install command. The views'
+   not-installed banner (see `/add-list-view`'s `CrdInstalledGate`) links to these, so also put
+   `{ name, docsUrl, installCmd }` in `src/resources/common.ts` as the single source of truth.
+
+7. **Seed test data.** A fresh operator has no instances, so views are still empty — hand off to
+   **`/seed-test-data`**.
+
+## Guardrails
+
+- The helm MCP is **install/upgrade only** (no delete, no arbitrary kubectl). To remove a project,
+  tell the user to run `helm uninstall  -n ` themselves.
+- No version pinning via the MCP (installs latest). For a pinned version, the user installs with the
+  `helm` CLI (`--version X.Y.Z`) and you proceed once it's present.
+- Installing mutates the user's cluster — confirm the exact chart/namespace/cluster first, and
+  report what was installed.
\ No newline at end of file
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/plan-plugin/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/plan-plugin/SKILL.md
new file mode 100644
index 00000000000..2f8223f6b34
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/plan-plugin/SKILL.md
@@ -0,0 +1,170 @@
+---
+name: plan-plugin
+description: Plan this CNCF/CRD Headlamp plugin before any UI code is written. In one holistic pass over ALL the project's CRDs, decide — from the LIVE cluster + seeded data — the scope, the typed resource model, sidebar entries & grouping, per-CRD list columns (from `kubectl get -o wide` + the CRD's printer columns), per-CRD detail layout (from `kubectl describe` + the CRD's openAPIV3Schema), the Map relationships, settings, and i18n/a11y obligations. Writes PLAN.md — the source of truth the build sub-skills consume. Use after setup (cluster chosen, project installed, CRDs discovered, test data seeded) and before /add-list-view, /add-detail-view, /add-settings.
+allowed-tools:
+  - mcp__kubernetes__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl describe:*)
+  - Bash(kubectl explain:*)
+  - Bash(kubectl config get-contexts:*)
+  - Bash(kubectl config current-context:*)
+  - Write(PLAN.md)
+  - Edit(PLAN.md)
+  - WebFetch
+  - WebSearch
+---
+
+# plan-plugin
+
+Plan the whole plugin **once, holistically, from the live cluster** — then build to the plan.
+This is the highest-leverage step: a list view is only as good as the column set you derived from
+`kubectl get -o wide`, and a detail view is only as complete as the `kubectl describe` you modeled
+it from. Plan all CRDs together so the navigation and shared types are coherent, not bolted on.
+
+**This skill writes `PLAN.md` and writes NO code.** The build sub-skills read it. Decide everything
+here from real data — never from guessed resource shapes.
+
+## Prerequisites (from setup)
+
+The project is identified, a cluster is chosen, the project is installed, its CRDs are enumerated,
+and **test data is seeded and applied** — so `kubectl describe` shows real, populated fields and
+`kubectl get -o wide` shows real values. If any are missing, finish setup first (see
+`/create-crd-plugin` Phase 0).
+
+## 1) Scope & typed resource model
+
+List **every** CRD in the project's API group(s) (the discovery set). For each, record from the
+cluster — don't recall from memory:
+
+- `kubectl get crd . -o yaml` → **group, version(s), `scope` (Namespaced?), kind,
+  plural/singular names**, and the **`spec.versions[].schema.openAPIV3Schema`** (the field shape).
+- `kubectl explain .spec` / `.status` to sanity-check the shape interactively.
+
+From that, plan each `src/resources/.ts`: the `spec`/`status` **interface** (`extends
+KubeObjectInterface`), the **`class … extends KubeObject`** with static
+apiVersion/kind/apiName/isNamespaced, and the **typed getters** the views read (`obj.componentType`,
+not `obj.jsonData?.spec?.…?`) — including a **`readyStatus`/status getter + a status→severity mapper**
+(reused by list + detail). Note shared enums/types → `src/resources/common.ts`. If you intentionally
+skip a CRD, say which and why. (This plan is **implemented** by `/define-resource`, the first build
+step per CRD — track it separately in Status.)
+
+## 2) Navigation — sidebar entries, grouping, routes
+
+- **Flat by default:** ONE parent entry + one child per CRD.
+- **Sub-group when it helps:** if the project has many CRDs *and* they fall into natural sub-domains
+  (e.g. Dapr building-blocks vs. management), group children under labelled parents. Judgement call —
+  record the grouping and reasoning in PLAN.md for review.
+- **Landing page (contextual):** a **small** plugin points the parent entry's `url` at the **primary
+  resource list** (no Overview). A **many-CRD / sub-grouped** plugin gets a dedicated **Overview**
+  landing page (a dashboard: per-CRD counts, health summary, quick links) — the flux/volcano/cluster-api
+  pattern. Decide which here and note it.
+- Map each entry to a **route**: cluster-relative `path`, the `component`, and the `sidebar` name
+  that highlights it. **Namespace everything under the project** — route `path`s (`//…`),
+  route `name`s and sidebar entry `name`s (`-…`) share a global namespace with Headlamp core
+  and other plugins, so prefix them or they collide. Multi-CRD plugins register via a **config array +
+  `registerResource()` loop** (`/add-list-view` §2).
+
+## 3) List view columns (per CRD) — curate for value, don't just mirror `kubectl get`
+
+`kubectl get` is the **floor and the candidate source, not the final column set.** Gather the
+candidates, then decide which columns genuinely help an operator scan and triage *this* resource —
+the goal is a useful table, not a mechanical copy of the printer columns.
+
+- **Candidates** (every column sourced from a real field — never an invented value):
+  - `kubectl get ` → the default printer columns.
+  - `kubectl get  -o wide` → the wide extras.
+  - the CRD's **`additionalPrinterColumns`** → columns the project itself defines.
+  - **the CRD schema / a live object** → high-value `spec`/`status` fields that **no printer column
+    exposes** (a ready/phase summary, replica counts, a key target ref, a last-transition reason).
+    Add these as columns when they add triage value — printer columns are often a thin subset.
+- **Curate:**
+  - **Keep the `kubectl get` default columns visible** (what an operator expects) — don't silently
+    drop them, unless one is genuinely noise (say why).
+  - **Promote to visible** the wide/printer/schema fields users actually scan for (status/ready,
+    target, key counts). "Visible" is not limited to the kubectl defaults.
+  - **Hide** (`show: false`, toggleable) the lower-value extras — long IDs, verbose internal fields.
+  - **Status/health/phase columns must use `StatusLabel`** (severity color + text), not a raw status
+    string — via a custom `render` while `getValue` returns the raw string for sort/filter. When the
+    status is **condition-backed** (has a reason/message), plan the shared **`ReadyStatusLabel`
+    wrapper** (StatusLabel + reason/message tooltip); for a simple enum/phase, bare `StatusLabel` is
+    fine. Plan a single status→severity mapping per CRD, reused by the detail view.
+
+Record per column: the source field (a getter from step 1), built-in `ColumnType`
+(`'name'`,`'namespace'`,`'age'`, …) vs custom `{ id, label, getValue, render?, show? }`, its
+**default visibility, and a one-word why** (so the curation is reviewable). The list renders with
+`ResourceListView resourceClass={}`.
+
+## 4) Detail view layout (per CRD) — from `kubectl describe`
+
+For each CRD, model the page to **`kubectl describe /` parity** — render the WHOLE
+object, not a few picked rows:
+
+- `kubectl describe /` (against a seeded instance) + the openAPIV3Schema → enumerate
+  every meaningful `spec`/`status` field.
+- Plan the layout: headline scalars → `DetailsGrid` `extraInfo`; each logical `spec`/`status` group
+  → one `SectionBox`/`NameValueTable` in `extraSections`. **`status.conditions` goes LAST**, via
+  `ConditionsSection`, so it sits directly above the auto Events section. (Edit/Delete/Scale/Restart
+  header actions are free from DetailsGrid — plan only project-specific ones, see Actions below.)
+- Plan **related/owner links** (target Deployment/HPA, Secret, ConfigMap, …) as `Link`s.
+- Plan **project-specific actions** (context-dependent — decide per CRD): Edit/Delete/Scale/Restart
+  come free from DetailsGrid, so only plan *project* mutations that make sense for this CRD's
+  semantics (flux suspend/resume, keda pause, "trigger reconcile", …). Many CRDs need none. For each
+  planned action note the API verb/path and the confirm prompt (`/add-detail-view` shows how).
+- A **specific free-form field** (embedded YAML/template, script, cert) → an inline read-only Monaco
+  `CodeBlock`. (Whole-object YAML is the free default Edit action — don't plan a custom one.) Never a
+  `
` dump.
+
+## 5) Settings — plan these EARLY (the views may depend on them)
+
+Settings are not an afterthought: if any view reads a setting (refetch interval, a feature toggle, a
+default namespace, default column visibility, a metrics endpoint), the **config module is a
+dependency of that view** — so it must be planned now and **built before the views** (see build
+order below). Decide:
+
+- **Does the plugin need settings at all?** If genuinely none, say "none" and move on.
+- **What each setting is** — name, type, default, and **which views/behaviours consume it**. That
+  consumer list is what dictates build order.
+- **Scope of each setting — plugin-wide vs. per-cluster.** `ConfigStore` is a single per-browser
+  store, so:
+  - **Plugin-wide** (the default): one flat config object (e.g. `{ refetchInterval, showRawYaml }`).
+  - **Per-cluster**: the same plugin can target many clusters, and a setting may need to differ per
+    cluster (a cluster-specific metrics URL, a per-cluster default namespace). Model it by **keying
+    the config by cluster name** (`{ clusters: { [cluster]: {...} }, defaults: {...} }`) and reading
+    the current cluster's slice via `getCluster()`. Decide per setting which scope it is and record it.
+- **Build order:** if any view consumes settings, the orchestrator runs `/add-settings` (at least the
+  `src/config.ts` module + defaults) **before** `/define-resource`/`/add-list-view` for the consuming
+  CRDs. A settings page that nothing reads can be built last.
+
+## 6) Map, i18n/a11y
+
+- **Map:** plan one leaf `GraphSource` per CRD (nodes from the typed class, edges to related
+  objects) grouped under ONE project parent source; note the per-Kind icons. (Both the source
+  `icon` *and* `registerKindIcon` are needed — see CLAUDE.md.)
+- **i18n / a11y obligations:** list which strings are prose to wrap in `t(...)` vs. plain-English
+  labels, and which controls (icon-only buttons, status glyphs) need accessible names.
+
+## Output — write `PLAN.md`
+
+Write a committed `PLAN.md` at the plugin root with these sections (every section is the *current*
+truth; build sub-skills update it in place as views land):
+
+```markdown
+#  — plan
+## Project & cluster   — project, group(s), chosen cluster, installed version
+## Scope               — every CRD (kind, group/version, namespaced?) + any skip + reason
+## Resource model      — per CRD: src/resources/, spec/status interface, getters
+## Navigation          — sidebar tree (flat or grouped + why), LANDING (primary list | dedicated Overview), routes
+## List views          — per CRD: column table (field/getter, ColumnType|custom, show, status→StatusLabel)
+## Detail views        — per CRD: extraInfo scalars, sections (spec/status groups), conditions, links, code blocks
+## Actions             — per CRD: project-specific mutations (verb/path/confirm) or "defaults only"
+## Map                 — leaf sources, edges, kind icons
+## Settings            — needed? per setting: name, type, default, SCOPE (plugin-wide|per-cluster), which views consume it (→ build order). Or "none".
+## i18n / a11y         — prose to translate, controls needing accessible names
+## Status              — per CRD, distinct steps: model defined (/define-resource) · list (/add-list-view) · detail (/add-detail-view) · verified
+```
+
+## Review checkpoint
+
+Before handing off to the build skills, **present the plan to the user** — especially the
+**navigation grouping** (the one judgement call) and the **CRD scope**. Adjust on feedback, then
+proceed to `/add-list-view` and `/add-detail-view` per CRD.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/run-and-verify/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/run-and-verify/SKILL.md
new file mode 100644
index 00000000000..e5c64a346a4
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/run-and-verify/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: run-and-verify
+description: Bring up the live development loop and prove the plugin actually loads — the user launches Headlamp with remote debugging, you run the watch build, attach the chrome-devtools MCP, and run the non-skippable load smoke check (no Plugin execution error, contribution renders, i18n resolves, a11y tree has names/roles). Use to develop, live-verify, or re-check the plugin after any change. This is where gate-green ≠ loads is enforced.
+allowed-tools:
+  - mcp__chrome-devtools__*
+  - Bash(npm start:*)
+  - Bash(npm run tsc:*)
+  - Bash(npm run lint:*)
+  - Bash(npm run lint-fix:*)
+  - Bash(npm run build:*)
+  - Bash(curl:*)
+  - Bash(kubectl get:*)
+  - Write(src/**)
+  - Edit(src/**)
+  - Edit(PLAN.md)
+---
+
+# run-and-verify
+
+Run the plugin inside the real Headlamp app and use the browser session as ground truth. A green
+`tsc`/`lint`/`build` does **not** mean the plugin loaded — a value imported from a non-externalized
+path is `undefined` at runtime and throws on load while the build stays green (CLAUDE.md). This skill
+is where that's caught.
+
+## Who launches the app
+
+**The user launches the Electron GUI from their own terminal — you attach, you don't launch.**
+Electron won't initialize from a headless background shell. Ask the user to start it and confirm
+`:9222` is up before attaching.
+
+## Steps
+
+1. **User launches Headlamp with remote debugging.** Either:
+   - from the Headlamp source repo: `npm run start:with-app:debug` (backend `:4466` + Vite `:3000` +
+     Electron `--remote-debugging-port=9222`) — **free `:4466`/`:3000`/`:9222` first**; a stale
+     backend makes it silently fall back; or
+   - the installed app: `/Applications/Headlamp.app/Contents/MacOS/Headlamp --remote-debugging-port=9222 --user-data-dir=/tmp/headlamp-debug`.
+
+   Cluster-scoped route URLs are `http://localhost:3000/#/c//`.
+
+2. **Confirm the CDP target** the MCP will attach to:
+   ```bash
+   curl -s http://localhost:9222/json | grep -o '"url":"[^"]*"'   # note the type:"page" Headlamp UI target
+   ```
+
+3. **Start the watch build** (background it; rebuilds on save, app hot-reloads):
+   ```bash
+   npm start
+   ```
+
+4. **Attach + run the load smoke check (NON-SKIPPABLE)** via the chrome-devtools MCP:
+   - `list_console_messages` → **no `Plugin execution error in `** (Headlamp logs exactly this
+     when a plugin throws on load) and no other plugin errors.
+   - `list_pages`/`select_page` → the Headlamp UI page; `navigate_page` to your route.
+   - `take_snapshot` → your **actual contribution** is in the DOM/a11y tree (sidebar entry / route
+     content / widget) — "the app rendered" ≠ "the plugin loaded".
+   - **i18n resolves** — custom column headers/labels show real text. Blank custom headers while
+     built-in ones (Namespace/Age) show = the plugin's translation namespace didn't load (stale/empty
+     bundle): `npm run build` (check `dist/locales//translation.json` is non-empty), **fully
+     restart Headlamp**, and `evaluate_script` `t('X')` to confirm it's non-empty.
+   - **a11y** — every interactive control in the snapshot has a name/role.
+   - `take_screenshot` for visual confirmation; `evaluate_script` for specific state.
+
+   **If the contribution is missing or the console shows a plugin execution error, the plugin did
+   NOT load.** Re-run this after **every new value import or `extends`** — that's the change that
+   introduces this bug invisibly.
+
+5. **Iterate.** On an error/missing contribution/wrong render: read the message, fix `src/` (suspect a
+   non-externalized value import first), let the watch build reload, re-check. **Never hand-create a
+   symlink to force a not-loading plugin** — a missing contribution is a load-time crash or a wrong
+   route/sidebar name, not a placement problem; `npm start` already links the build where the app
+   loads it.
+
+6. **Close the gate + teardown.** Once behavior is right, run `npm run tsc && npm run lint && npm run
+   build` so the production build matches what you verified. When the session is over, stop the watch
+   build and remove the dev-load artifact `npm start` left in Headlamp's **dev-plugins** dir
+   (`~/Library/Application Support/Headlamp/dev-plugins/` on macOS) so the app stops loading the
+   dev version — `rm` only that symlink/dir under the Headlamp config base, never the repo source. To
+   keep the plugin installed permanently, use the packaged tarball.
+
+## MCP notes
+
+- The chrome-devtools MCP attaches to `http://127.0.0.1:9222` (`.mcp.json`). It only connects once
+  Headlamp is up (loaded ≠ connected) — launch the app first.
+- If `mcp__chrome-devtools__*` tools don't exist: project `.mcp.json` servers load at **startup**, so
+  a just-added server needs a **Claude Code restart** (not just `/mcp`); and a server listed in
+  `disabledMcpjsonServers` (settings.local.json) is blocked until removed.
+- Operate on the React UI `page` target (Electron exposes several); evaluated scripts see the
+  DOM/React UI only, not Node/Electron internals.
diff --git a/plugins/headlamp-plugin/template-claude/.claude/skills/seed-test-data/SKILL.md b/plugins/headlamp-plugin/template-claude/.claude/skills/seed-test-data/SKILL.md
new file mode 100644
index 00000000000..949e890a21d
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.claude/skills/seed-test-data/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: seed-test-data
+description: Create real test data for this plugin — author sample custom resources (from the project's docs, validated against the live CRD schema) under test-files/deploy/ named by state, write the required test-files/README.md (manual setup + scenario→state matrix), build any custom images a scenario needs, and apply them to the chosen cluster so the plugin renders against real objects. Use after the operator is installed but the cluster has no instances yet.
+allowed-tools:
+  - mcp__kubernetes__*
+  - Bash(kubectl get:*)
+  - Bash(kubectl explain:*)
+  - Bash(kubectl config use-context:*)
+  - Bash(kubectl apply -f test-files:*)
+  - Bash(kubectl delete -f test-files:*)
+  - Bash(kubectl create namespace:*)
+  - Bash(docker build:*)
+  - Bash(kind load:*)
+  - Bash(minikube image load:*)
+  - Write(test-files/**)
+  - Edit(test-files/**)
+  - Edit(PLAN.md)
+  - WebFetch
+  - WebSearch
+---
+
+# seed-test-data
+
+Installing the operator gives you the CRDs but **no instances** — so the views are still empty.
+This skill creates realistic sample resources (and any workloads a scenario needs) so the plugin can
+be built and verified against real data, covering the distinct states the UI must render. Model the
+layout on KEDA's `test-files/` (https://github.com/headlamp-k8s/plugins/tree/main/keda/test-files).
+
+## Required deliverables — not done until ALL exist
+
+```
+- [ ] test-files/deploy/*.yaml   — sample CRs named by state (…-running.yaml, …-failed.yaml, …) + supporting workloads
+- [ ] test-files/README.md       — REQUIRED: manual-setup walkthrough + scenario→state matrix (step 5)
+- [ ] (if needed) test-files// + Dockerfile — custom image, built/loaded
+- [ ] applied to the cluster and verified (CRs reach expected states; plugin renders them)
+```
+
+The **README is the most-skipped item** — author it as part of seeding. One file per state under
+`test-files/deploy/` (not one combined YAML).
+
+## Steps
+
+1. **Source specs from the project's docs** (WebFetch/WebSearch the official examples) — don't
+   invent fields — then **validate against the live schema**: `kubectl explain .spec` / the
+   CRD's `openAPIV3Schema` (kubernetes MCP), so required fields/enums are right for the installed
+   version.
+
+2. **Author fixtures under `test-files/deploy/`**, deliberately covering the **distinct states the
+   plugin renders** — healthy/**Running**, **Failed/Error**, **Warning/Degraded**, and any
+   project-specific states (Paused, Fallback, …). Name files by the state they produce
+   (`scaledobject-running.yaml`, `scaledobject-failed.yaml`). Every status column/badge the UI shows
+   should have a fixture that exercises it.
+
+3. **Custom images — when a scenario needs real activity** (e.g. a load generator so a trigger
+   fires): create the app source + `Dockerfile` under `test-files/`, `docker build`, and make it
+   available — kind: `kind load docker-image `; minikube: `minikube image load `;
+   docker-desktop: local image + `imagePullPolicy: IfNotPresent`; remote: push to a reachable registry.
+
+4. **Apply to the cluster (permission-gated). ⛔** Applying mutates the cluster — **ask first**, then
+   (cluster MCP is read-only, helm MCP is install-only, so use the CLI):
+   ```bash
+   kubectl apply -f test-files/deploy/ --context  -n 
+   ```
+   Build/load any image before applying workloads that reference it. Verify via the kubernetes MCP
+   that the CRs reach the expected states; record the scenarios → states in `PLAN.md` (Test fixtures).
+
+5. **Write `test-files/README.md` (required)** so a human can reproduce every scenario:
+   - **Use case & prerequisites** — what the plugin shows, operator/version, cluster/namespace, any
+     external dependency (broker, registry).
+   - **Manual setup, step by step** — exact commands in order (build/load image; `kubectl apply`
+     order, workloads before the CRs that target them; **how to induce each state**).
+   - **Scenario → state matrix:**
+
+     | Fixture | Produces | Plugin should show |
+     |---|---|---|
+     | `deploy/scaledobject-running.yaml` | Healthy / **Running** | Ready=True, Active, green status |
+     | `deploy/scaledobject-failed.yaml` | **Failed** (bad trigger auth) | Ready=False, error reason in Status |
+     | `deploy/scaledobject-paused.yaml` | **Paused** (annotation) | Paused badge, replicas frozen |
+
+   - **Cleanup** — `kubectl delete -f deploy/ --context  -n `.
+
+## Guardrails
+
+- Don't fabricate CR specs — source from docs, validate against the live CRD schema. A spec the
+  apiserver rejects yields no data.
+- Applying fixtures / building / pushing images are cluster/outward actions — confirm cluster,
+  namespace, image target first; report what was applied.
+- Cleanup is the user's call (the cluster MCP can't delete) — give them the `kubectl delete` command.
+- Keep secrets out of fixtures — use obvious placeholder credentials and say so.
+- `test-files/` is committed, so the scenario is reproducible and documents how to exercise the plugin.
diff --git a/plugins/headlamp-plugin/template-claude/.mcp.json b/plugins/headlamp-plugin/template-claude/.mcp.json
new file mode 100644
index 00000000000..4e73a7b8afd
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/.mcp.json
@@ -0,0 +1,22 @@
+{
+  "mcpServers": {
+    "kubernetes": {
+      "type": "stdio",
+      "command": "npx",
+      "args": ["-y", "kubernetes-mcp-server@latest", "--read-only"]
+    },
+    "helm": {
+      "type": "stdio",
+      "command": "npx",
+      "args": ["-y", "mcp-server-kubernetes@latest"],
+      "env": {
+        "ALLOWED_TOOLS": "install_helm_chart,upgrade_helm_chart"
+      }
+    },
+    "chrome-devtools": {
+      "type": "stdio",
+      "command": "npx",
+      "args": ["-y", "chrome-devtools-mcp@latest", "--browser-url=http://127.0.0.1:9222"]
+    }
+  }
+}
diff --git a/plugins/headlamp-plugin/template-claude/CLAUDE.md b/plugins/headlamp-plugin/template-claude/CLAUDE.md
new file mode 100644
index 00000000000..d06a6c38281
--- /dev/null
+++ b/plugins/headlamp-plugin/template-claude/CLAUDE.md
@@ -0,0 +1,120 @@
+# Headlamp plugin — agent rules
+
+This is a **Headlamp plugin**: React 18 + TypeScript + MUI v5, built by the
+`@kinvolk/headlamp-plugin` toolchain into `dist/main.js`. At runtime the Headlamp frontend
+fetches that bundle and runs it; the plugin's job is to call `register*` functions to extend
+the UI. Plugins **never call the kube-apiserver directly** — all cluster data flows through the
+Headlamp backend proxy via the lib's `K8s` / `ApiProxy` helpers.
+
+This file is always-on policy. Step-by-step workflows live in [.claude/skills/](.claude/skills/).
+- **Building this plugin out for the first time (CNCF/CRD project)?** Run `/create-crd-plugin` —
+  the day-one loop (cluster → install → seed data → plan → build → live-test → document).
+- **Maintaining or extending an existing plugin?** Call the sub-skills directly — `/define-resource`,
+  `/add-list-view`, `/add-detail-view`, `/add-settings`, `/run-and-verify`. They work standalone;
+  don't re-run the whole loop for a small change.
+
+## Commands (run in this plugin folder)
+
+- `npm start` — watch + rebuild into Headlamp's **dev-plugins** dir; the desktop app hot-reloads. Primary dev loop.
+- `npm run tsc` — type-check (gate). `npm run lint` / `lint-fix` — eslint, the **a11y gate** (`jsx-a11y/recommended`).
+- `npm run build` — production build → `dist/main.js`. `npm run test` — vitest.
+- `npm run i18n` — extract `t(...)` keys into `locales//translation.json`. `npm run package` — `.tar.gz` tarball.
+
+Don't reference scripts that aren't in `package.json`; fall back to `npx @kinvolk/headlamp-plugin `.
+
+**Run bash commands directly — your working directory is already the plugin root. Do NOT prefix with
+`cd ; …`** (the cwd persists between calls). A `cd …;` prefix makes the command start with `cd`,
+which matches none of the permission allow-rules and forces a needless approval prompt for every command.
+Prefer the dedicated Read/Grep/Glob tools over `cat`/`ls`/`find`/`grep`, and don't use `echo` for
+section headers — just write prose.
+
+## Golden rules
+
+- **The toolchain owns config.** Don't hand-edit `package.json` build fields, `tsconfig.json`, or
+  webpack/vite config — `@kinvolk/headlamp-plugin` owns them.
+- **`src/index.tsx` is registration-only.** UI lives in `src/components//{List,Detail}.tsx`
+  + `src/components/common/`; typed custom-resource classes in `src/resources/`; the Map in
+  `src/mapView.tsx`; settings in `src/config.ts` + `src/settings.tsx`. Don't drop loose components at
+  `src/` root or invent a flat `src/views/`.
+- **Use function-style `register*`** (`registerSidebarEntry`, `registerRoute`,
+  `registerDetailsViewSection`, …) from `@kinvolk/headlamp-plugin/lib`. The class API
+  (`Plugin.initialize` / `Headlamp.registerPlugin`) is legacy — only for editing existing legacy code.
+- **Reuse the lib before writing UI.** Pull `SectionBox`, `SimpleTable`, `NameValueTable`, `Link`,
+  `Loader`, `ResourceListView`, `DetailsGrid`, `ConditionsTable`, … from
+  `@kinvolk/headlamp-plugin/lib/CommonComponents`, and `@mui/material`, before custom components.
+  Import MUI **per component**: `import Box from '@mui/material/Box'`.
+
+- **⚠ Runtime externalization — import VALUES only from externalized barrels (this is the #1
+  silent load-crash, and `tsc`/`lint`/`build` CANNOT catch it).** The build marks
+  `@kinvolk/headlamp-plugin/lib` (+ submodules), React, MUI, `@iconify/react`, `@monaco-editor/react`
+  and more as *external*; at runtime they resolve to Headlamp globals (`pluginLib.*`). A deep import
+  path is rewritten to a global by prefix — e.g. `…/lib/k8s/cluster` → `pluginLib.K8s.cluster.*`. If
+  that global doesn't actually exist at runtime, the **value is `undefined` at load and crashes the
+  whole plugin** — while the build stays green because the *type* resolved from the `.d.ts`. So:
+  - Import **values** (a class you `extends`, a function you call, a component you render) from a
+    documented, externalized path: `@kinvolk/headlamp-plugin/lib`, `/lib/CommonComponents`,
+    `/lib/k8s/cluster` (→ `pluginLib.K8s.cluster` — `KubeObject` lives here), `/lib/K8s`, `/lib/Utils`,
+    `/lib/ApiProxy`, `/lib/Router`, `/lib/Notification`, `/lib/Crd`. The externalized-modules map is
+    `node_modules/@kinvolk/headlamp-plugin/config/vite.config.mjs`.
+  - **Typed custom resources use `class X extends KubeObject`** with `KubeObject` +
+    `KubeObjectInterface` value/type-imported from `@kinvolk/headlamp-plugin/lib/k8s/cluster` — the
+    standard across the official CNCF plugins (keda, cert-manager, …) and runtime-safe (it resolves to
+    `pluginLib.K8s.cluster.KubeObject`, which IS provided). See [/define-resource]. (`makeCustomResourceClass`
+    from `/lib/Crd` is the alternative for factory/dynamic CRDs generated from config — radius/karpenter.)
+  - Import **types** from any deep path (`import type { ... } from
+    '@kinvolk/headlamp-plugin/lib/k8s/...'`) — types are erased at build, so deep type imports are safe.
+  - **The trap is importing a value from a NON-externalized deep path** — it rewrites to a `pluginLib.*`
+    global that doesn't exist → `undefined` at load → the whole plugin crashes, while the build stays
+    green (the type resolved from the `.d.ts`). **Only a live load proves a value import works** (see
+    Definition of done). Re-check after every new value import or `extends`.
+
+- **i18n user-facing prose; keep a11y green.** Wrap genuine prose (titles, descriptions,
+  status/empty/error messages, buttons, dialogs, settings) in `t(...)` — `const { t } =
+  useTranslation();` from `@kinvolk/headlamp-plugin/lib`. Short column/field labels (`Name`, `Type`,
+  `Age`) may stay plain English. `useTranslation` is a hook — labels passed at module scope
+  (sidebar/route names) stay plain English. Run `npm run i18n` and ensure `en` values are non-empty
+  (empty value → `t('X')` renders blank). For a11y: keep `npm run lint` clean, give icon-only controls
+  an accessible name, never encode meaning in color/glyph alone.
+
+CRD-specific craft (typed classes, `kubectl get`/`describe` parity, the Map + icon traps, the
+not-installed banner) is in the skills — pulled in on demand, not pasted here.
+
+## Definition of done — gate-green ≠ loads
+
+A plugin is "working" only after, in order:
+
+1. `npm run tsc` clean → 2. `npm run lint` clean (also the a11y gate) → 3. `npm run build` produces `dist/main.js`.
+4. **Live-load smoke check — NON-SKIPPABLE.** `tsc`/`lint`/`build` passing does **not** mean the plugin
+   loads (a value imported from a non-externalized path is `undefined` at runtime and crashes the
+   plugin while the build stays green — see the externalization rule). With Headlamp running and the
+   plugin loaded, via the chrome-devtools MCP:
+   - (a) `list_console_messages` shows **no `Plugin execution error in `** and no plugin errors;
+   - (b) `take_snapshot` confirms the plugin's actual contribution is in the DOM/a11y tree (sidebar
+     entry / route renders / widget shows) — not just "the app loaded";
+   - (c) **i18n resolves** — custom labels/headers show real text, not blank (a stale/empty locale
+     bundle makes `t('X')` return `''`; built-in columns still show because they use core namespaces);
+   - (d) the a11y tree gives every interactive control a name/role.
+
+   Re-run this whenever you add a new **value import** or an `extends`. "Gate-green" is not "loads."
+
+## register* quick reference (from `@kinvolk/headlamp-plugin/lib`)
+
+- `registerSidebarEntry({ name, label, url?, parent?, icon?, sidebar? })` — `parent` nests it; for a
+  multi-resource plugin register ONE parent that carries its own landing `url` + a child per resource.
+  Landing = the primary list for a small plugin, or a dedicated **Overview** page for a many-CRD one
+  (see [/plan-plugin]). Drive multi-CRD registration from a config array + a `registerResource()` loop.
+- `registerRoute({ path, sidebar, component, exact?, name? })` — `path` is cluster-relative.
+  **Namespace everything under the project name** to avoid clashing with Headlamp core routes and other
+  plugins (one global namespace): route `path` → `//…`; route `name`, sidebar entry `name`,
+  table `id` → `-…` (use the `package.json` name as ``).
+- `registerDetailsViewSection` / `registerDetailsViewHeaderAction` / `registerResourceTableColumnsProcessor`
+  — extend built-in resource pages.
+- `registerMapSource(source)` / `registerKindIcon(kind, { icon, color })` — Map/topology + Kind icons.
+- `registerPluginSettings(name, Component, displaySaveButton?)` — settings page.
+- Cluster data: `K8s.ResourceClasses..useList(...)` / `.useGet(...)`; custom resources via your
+  typed `src/resources/.ts` class (`.useList()`, `resourceClass={}`).
+- Writes/uncovered endpoints: `ApiProxy.request(path, { cluster, method?, body? })`.
+
+Confirm exact signatures against `node_modules/@kinvolk/headlamp-plugin/examples/` and
+`official-plugins/` (keda, cert-manager, flux), and the
+[API reference](https://headlamp.dev/docs/latest/development/api/).
diff --git a/plugins/headlamp-plugin/test-headlamp-plugin.js b/plugins/headlamp-plugin/test-headlamp-plugin.js
index 43c6542777f..8d345366832 100755
--- a/plugins/headlamp-plugin/test-headlamp-plugin.js
+++ b/plugins/headlamp-plugin/test-headlamp-plugin.js
@@ -24,6 +24,7 @@ This tests unpublished @kinvolk/headlamp-plugin package in repo.
 Assumes being run within the plugins/headlamp-plugin folder
 `;
 const PACKAGE_NAME = 'headlamp-myfancy';
+const CLAUDE_PACKAGE_NAME = 'headlamp-myfancy-claude';
 
 function testHeadlampPlugin() {
   // remove some temporary files.
@@ -184,6 +185,55 @@ function testHeadlampPlugin() {
   if (fs.readFileSync(indexTsxPath, 'utf8').includes('@material-ui')) {
     exit(`Error: @material-ui imports in ${indexTsxPath}`);
   }
+
+  // test "create --with-claude-skills" scaffolds the Claude Code agent harness
+  // and drops the default AGENTS.md in favour of CLAUDE.md.
+  curDir = '.';
+  fs.rmSync(CLAUDE_PACKAGE_NAME, { recursive: true, force: true });
+  run('node', [
+    'bin/headlamp-plugin.js',
+    'create',
+    CLAUDE_PACKAGE_NAME,
+    '--link',
+    '--noinstall',
+    '--with-claude-skills',
+  ]);
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, 'CLAUDE.md'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, '.mcp.json'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, '.claude', 'settings.json'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, '.claude', 'skills', 'create-crd-plugin', 'SKILL.md'));
+  // CLAUDE.md supersedes AGENTS.md when the harness is added.
+  checkFileDoesNotExist(join(CLAUDE_PACKAGE_NAME, 'AGENTS.md'));
+  // The default scaffold must NOT include the harness.
+  checkFileDoesNotExist(join(PACKAGE_NAME, 'CLAUDE.md'));
+  fs.rmSync(CLAUDE_PACKAGE_NAME, { recursive: true, force: true });
+
+  // test "upgrade --with-claude-skills" opts a default plugin into the harness:
+  // it adds CLAUDE.md/.mcp.json/.claude and drops the default AGENTS.md.
+  run('node', ['bin/headlamp-plugin.js', 'create', CLAUDE_PACKAGE_NAME, '--link', '--noinstall']);
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, 'AGENTS.md'));
+  checkFileDoesNotExist(join(CLAUDE_PACKAGE_NAME, 'CLAUDE.md'));
+  run('node', [
+    'bin/headlamp-plugin.js',
+    'upgrade',
+    CLAUDE_PACKAGE_NAME,
+    '--skip-package-updates',
+    '--with-claude-skills',
+  ]);
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, 'CLAUDE.md'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, '.mcp.json'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, '.claude', 'skills', 'plan-plugin', 'SKILL.md'));
+  checkFileDoesNotExist(join(CLAUDE_PACKAGE_NAME, 'AGENTS.md'));
+  // a plain upgrade must not reintroduce AGENTS.md once CLAUDE.md is present.
+  run('node', [
+    'bin/headlamp-plugin.js',
+    'upgrade',
+    CLAUDE_PACKAGE_NAME,
+    '--skip-package-updates',
+  ]);
+  checkFileDoesNotExist(join(CLAUDE_PACKAGE_NAME, 'AGENTS.md'));
+  checkFileExists(join(CLAUDE_PACKAGE_NAME, 'CLAUDE.md'));
+  fs.rmSync(CLAUDE_PACKAGE_NAME, { recursive: true, force: true });
 }
 
 const fs = require('fs');
@@ -200,7 +250,11 @@ function cleanup() {
     .filter(file => file.match('kinvolk-headlamp-plugin-.*gz'))
     .forEach(file => fs.rmSync(file));
 
-  const foldersToRemove = [path.join('.plugins', PACKAGE_NAME), PACKAGE_NAME];
+  const foldersToRemove = [
+    path.join('.plugins', PACKAGE_NAME),
+    PACKAGE_NAME,
+    CLAUDE_PACKAGE_NAME,
+  ];
   console.log('Temp foldersToRemove', foldersToRemove);
   foldersToRemove
     .filter(folder => fs.existsSync(folder))
@@ -283,6 +337,11 @@ function checkFileExists(fname) {
     exit(`Error: ${fname} does not exist.`);
   }
 }
+function checkFileDoesNotExist(fname) {
+  if (fs.existsSync(fname)) {
+    exit(`Error: ${fname} exists but should not.`);
+  }
+}
 function exit(message) {
   console.error(message);
   cleanup();