diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3b1db..dac29f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,6 @@ jobs: - run: npm install -g @anthropic-ai/claude-code - run: claude plugin validate . - run: for p in plugins/*/; do claude plugin validate "$p" || exit 1; done + # The worked example in docs/mods.md. + - run: claude plugin validate docs/examples/homelab-guard + - run: claude plugin test docs/examples/homelab-guard diff --git a/README.md b/README.md index d72f1d1..786a04c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ # claude -Claude Code plugins for running a homelab, published as one marketplace named `jonasthim`. +Claude Code plugins for running a homelab, published as one marketplace named `jonasthim`. Talk to Claude +about your NAS, hypervisor or network and it inspects, troubleshoots and changes them through their official +APIs. It asks before anything destructive. -| Plugin | What it operates | Contents | -|---|---|---| -| [`truenas`](plugins/truenas) | TrueNAS SCALE 25.x: pools, datasets, snapshots, shares, apps, alerts, updates, replication | skill `truenas`, `tn.py` (JSON-RPC websocket or SSH `midclt`) | -| [`proxmox`](plugins/proxmox) | Proxmox VE 9: VMs, containers, snapshots, backups, storage, networking/SDN, HA, access control | skill `pve`, `/proxmox:*` commands, `proxmox-operator` agent, destructive-action guard hook | -| [`unifi`](plugins/unifi) | UniFi networks: client troubleshooting, health reports, guarded config changes, device actions | skill `unifi-ops`, `unifi.py` (Network Integration and Site Manager APIs), SSH diagnostics | +| Plugin | What it operates | You need | Contents | +|---|---|---|---| +| [`truenas`](plugins/truenas) | TrueNAS SCALE 25.x: pools, datasets, snapshots, shares, apps, alerts, updates, replication | API key, or SSH to the NAS | skill `truenas`, `tn.py` | +| [`proxmox`](plugins/proxmox) | Proxmox VE 9: VMs, containers, snapshots, backups, storage, networking/SDN, HA, access control | API token; SSH optional | skill `pve`, `/proxmox:*` commands, `proxmox-operator` agent, guard hook | +| [`unifi`](plugins/unifi) | UniFi networks: client troubleshooting, health reports, networks/VLANs, SSIDs, firewall, device actions | Integration API key; Site Manager key and SSH optional | skill `unifi-ops`, `unifi.py`, SSH diagnostics | + +Each plugin installs on its own, so you only load what you run. **Full documentation: [docs/](docs/index.md).** -Each plugin installs on its own, so you only load what you run. +## Quick start -## Install +**1. Install** ``` claude plugin marketplace add jonasthim/claude @@ -19,18 +23,71 @@ claude plugin install proxmox@jonasthim claude plugin install unifi@jonasthim ``` -or from inside a session: `/plugin marketplace add jonasthim/claude`, then `/plugin install @jonasthim`. +Or inside a session: `/plugin marketplace add jonasthim/claude`, then `/plugin install @jonasthim`. If +the repository is private and the add fails, clone it (`gh repo clone jonasthim/claude ~/src/claude`) and +run `claude plugin marketplace add ~/src/claude`. + +**2. Configure**: export credentials in the shell that starts Claude Code, ideally from a file only you can read: + +``` +# ~/.config/homelab.env (chmod 600), then: source ~/.config/homelab.env && claude +export TRUENAS_HOST=nas.example.lan TRUENAS_API_KEY='1-...' +export PVE_HOST=pve1.example.lan PVE_TOKEN_ID='claude@pve!ro' PVE_TOKEN_SECRET='...' +export PVE_CA_CERT=~/.config/pve-root-ca.pem +export UNIFI_HOST=192.168.1.1 UNIFI_API_KEY='...' +``` + +| Plugin | Where to create the credential | Read-only to start with | +|---|---|---| +| truenas | Web UI → your user icon → *API Keys* → *Add* | Key of a `READONLY_ADMIN` user | +| proxmox | On a node: `pveum user token add ...`, or *Datacenter → Permissions → API Tokens*. You create it, not Claude: the secret is shown once | `PVEAuditor` on `/` | +| unifi | Network app → *Settings → Control Plane → Integrations → API Keys* | Key of a read-only admin | + +Every variable is listed on the plugin pages: [TrueNAS](docs/truenas.md), [Proxmox VE](docs/proxmox.md), +[UniFi](docs/unifi.md). -If the repository is private, the marketplace add needs GitHub access that can read it (`gh auth login`, -a credential helper or an SSH key). Otherwise clone it and add the local path: +**3. Check the setup**: start a new session and ask "is my NAS healthy?", run `/proxmox:doctor`, or ask +"check my unifi setup". + +## What to ask + +| TrueNAS | Proxmox VE | UniFi | +|---|---|---| +| "Is my NAS healthy? Anything I should worry about?" | `/proxmox:status` for a cluster overview | "Why does my laptop keep dropping off wifi?" | +| "Snapshot tank/photos, then make the photos SMB share read-only for guests." | "Snapshot 101 before I upgrade it." | "Give me a health report." | +| "Jellyfin is stuck deploying, figure out why and fix it." | "Why did last night's backup of 105 fail?" | "Create a guest network on VLAN 30 with its own SSID." | +| "Which snapshots on tank/backup are older than 60 days?" | `/proxmox:vm`, `/proxmox:ct`, `/proxmox:snapshot`, `/proxmox:backup` | "Restart the hallway AP." | + +## Safety model + +- **Reads run freely.** +- **Destructive or disruptive actions need your yes.** Claude shows a plan first and waits for your answer. + A code-level gate backs this up: `tn.py --confirm`, `unifi.py --yes` (with `--dry-run` to preview), and + for Proxmox a guard hook that prompts even in auto mode. +- **Secrets stay out of the chat.** Claude never asks for or prints credentials, redacts them in output, and + never turns TLS verification off on its own. +- **Least privilege** is your last line: a read-only key or token cannot do damage. + +The details and a per-plugin comparison are in [docs/safety.md](docs/safety.md). + +## Update and uninstall ``` -gh repo clone jonasthim/claude ~/src/claude -claude plugin marketplace add ~/src/claude +claude plugin marketplace update jonasthim +claude plugin update @jonasthim +claude plugin uninstall @jonasthim ``` -Update later with `claude plugin marketplace update jonasthim` and `claude plugin update @jonasthim`. -Each plugin's README covers its configuration (environment variables, API keys, SSH) and safety model. +## Documentation + +| Page | | +|---|---| +| [Getting started](docs/getting-started.md) | Install, configure, first checks, moving from the old repositories | +| [TrueNAS](docs/truenas.md), [Proxmox VE](docs/proxmox.md), [UniFi](docs/unifi.md) | Credentials, variables, commands, safety and troubleshooting per plugin | +| [Safety model](docs/safety.md) | What runs on its own, what asks first, secrets and TLS | +| [Building a mod](docs/mods.md) | Function-hook plugins, with a worked, tested example | + +Each plugin's README under [`plugins/`](plugins) is its full reference. ## Develop @@ -42,12 +99,14 @@ bash plugins/proxmox/tests/run.sh # pro bash plugins/unifi/tests/smoke.sh # unifi (offline, against fixtures) ``` +UniFi also runs without a controller: `export UNIFI_MOCK_DIR=$PWD/plugins/unifi/skills/unifi-ops/evals/fixtures`. Shared conventions are in [CONTRIBUTING.md](CONTRIBUTING.md). ## Mods -The marketplace can also carry mods: Claude Code plugins of function hooks that add a pane, a band -above the prompt, a status line entry or a tool-call hook. None ship yet; see [mods/README.md](mods/README.md). +The marketplace can also carry mods: plugins made of function hooks that intercept tool calls, decide +permissions, or add a status line entry, a band or a pane. None ship yet. [docs/mods.md](docs/mods.md) walks +through building one. ## License diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..b909ae9 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,5 @@ +title: jonasthim homelab plugins for Claude Code +description: TrueNAS, Proxmox VE and UniFi plugins for Claude Code, from one marketplace. +theme: jekyll-theme-primer +exclude: + - examples diff --git a/docs/examples/homelab-guard/.claude-plugin/plugin.json b/docs/examples/homelab-guard/.claude-plugin/plugin.json new file mode 100644 index 0000000..46f5ad2 --- /dev/null +++ b/docs/examples/homelab-guard/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "homelab-guard", + "version": "0.1.0", + "author": { "name": "Jonas Thim" }, + "description": "Asks before any confirmed destructive TrueNAS, Proxmox or UniFi call runs." +} diff --git a/docs/examples/homelab-guard/hooks/guard.test.ts b/docs/examples/homelab-guard/hooks/guard.test.ts new file mode 100644 index 0000000..a889302 --- /dev/null +++ b/docs/examples/homelab-guard/hooks/guard.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'claude-code/testing' + +const check = (command: string) => ({ tool: 'Bash', input: { command } }) + +describe('homelab-guard', () => { + test('asks before a confirmed TrueNAS call', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + const verdict = await $.tool.check(check('python3 tn.py call pool.dataset.delete \'["tank/x"]\' --confirm')) + expect(verdict.decision).toBe('ask') + }) + + test('asks before a UniFi write and a lowercase Proxmox delete', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 unifi.py devices restart d1 --yes'))).decision).toBe('ask') + expect((await $.tool.check(check('pve-api.sh delete /nodes/pve1/qemu/100'))).decision).toBe('ask') + }) + + test('asks when the command is split across lines', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 tn.py call pool.dataset.delete \\\n \'["tank/x"]\' --confirm'))).decision).toBe('ask') + expect((await $.tool.check(check('python3 unifi.py wifi delete w1 \\\n --yes'))).decision).toBe('ask') + }) + + test('leaves reads and dry runs alone', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 tn.py call pool.query'))).decision).toBe('allow') + expect((await $.tool.check(check('python3 unifi.py devices restart d1 --dry-run'))).decision).toBe('allow') + }) + + test('never loosens a deny', async ($, on) => { + on('tool.check', () => ({ decision: 'deny', reason: 'blocked by a rule' })) + expect((await $.tool.check(check('python3 unifi.py wifi delete w1 --yes'))).decision).toBe('deny') + }) +}) diff --git a/docs/examples/homelab-guard/hooks/hooks.json b/docs/examples/homelab-guard/hooks/hooks.json new file mode 100644 index 0000000..5fcba7c --- /dev/null +++ b/docs/examples/homelab-guard/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.ts"] } diff --git a/docs/examples/homelab-guard/hooks/register.ts b/docs/examples/homelab-guard/hooks/register.ts new file mode 100644 index 0000000..fa2fc8a --- /dev/null +++ b/docs/examples/homelab-guard/hooks/register.ts @@ -0,0 +1,28 @@ +import type { Register } from 'claude-code' + +// Commands that carry a plugin's own "I have confirmation" flag, or an HTTP DELETE. +// Each entry: a pattern over the Bash command, then the reason the dialog shows. +// The `s` flag lets `.` cross newlines, so a command split with `\` is matched too. +const GATED: readonly [RegExp, string][] = [ + [/\btn\.py\b.*\s--confirm\b/s, 'a gated TrueNAS call (tn.py --confirm)'], + [/\bunifi\.py\b.*\s--yes\b/s, 'a UniFi write (unifi.py --yes)'], + [/\bpve-api\.sh\s+delete\b/i, 'an HTTP DELETE against Proxmox (pve-api.sh DELETE)'], +] + +export function gatedReason(command: string): string | undefined { + for (const [pattern, reason] of GATED) { + if (pattern.test(command)) return reason + } + return undefined +} + +export const register: Register = on => { + on('tool.check', { tool: 'Bash' }, async ($, e, next) => { + const verdict = await next(e) + const command = (e.input as { command?: unknown }).command + const reason = typeof command === 'string' ? gatedReason(command) : undefined + // Never loosen a deny; turn an allow into an ask for gated commands. + if (reason === undefined || verdict.decision === 'deny') return verdict + return { decision: 'ask', reason: `${$.plugin.name}: ${reason}. Confirm it is what you agreed to.` } + }).catch(() => ({ decision: 'ask', reason: 'homelab-guard failed; confirm this command yourself.' })) +} diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..98e6896 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,73 @@ +# Getting started + +## 1. Install + +From a terminal: + +``` +claude plugin marketplace add jonasthim/claude +claude plugin install truenas@jonasthim +claude plugin install proxmox@jonasthim +claude plugin install unifi@jonasthim +``` + +Or inside a Claude Code session: `/plugin marketplace add jonasthim/claude`, then +`/plugin install @jonasthim`. Install only the plugins for systems you run. + +If the repository is private, the marketplace add needs GitHub access that can read it (`gh auth login`, a +credential helper or an SSH key). Otherwise clone it and add the local path: + +``` +gh repo clone jonasthim/claude ~/src/claude +claude plugin marketplace add ~/src/claude +``` + +A plugin installed during a session becomes active in the next session (or after `/reload-plugins` where your +version has it). + +## 2. Configure + +Every plugin reads its connection settings from environment variables in the shell that starts Claude Code. +Keep them in a file only you can read and source it, so secrets never go into a prompt or a committed file: + +``` +# ~/.config/homelab.env (chmod 600) +export TRUENAS_HOST=nas.example.lan +export TRUENAS_API_KEY='1-...' + +export PVE_HOST=pve1.example.lan +export PVE_TOKEN_ID='claude@pve!ro' +export PVE_TOKEN_SECRET='...' +export PVE_CA_CERT=~/.config/pve-root-ca.pem + +export UNIFI_HOST=192.168.1.1 +export UNIFI_API_KEY='...' +``` + +``` +source ~/.config/homelab.env && claude +``` + +Start with read-only credentials and widen them when you need Claude to make changes. Where to create each +key, and every optional variable, is on the plugin pages: [TrueNAS](truenas.md), [Proxmox VE](proxmox.md), +[UniFi](unifi.md). + +## 3. First checks + +| Plugin | Ask or run | You should see | +|---|---|---| +| truenas | "Is my NAS healthy?" | Pool, alert and capacity summary | +| proxmox | `/proxmox:doctor` | A `check / result / detail` table and what the token can do per ACL path | +| unifi | "check my unifi setup" | The application version and site, or what is missing | + +## 4. Update and uninstall + +``` +claude plugin marketplace update jonasthim +claude plugin update @jonasthim +claude plugin uninstall @jonasthim +``` + +Moving from the old single-plugin repositories (`claude-truenas-skill`, `claude-proxmox-skill`, +`claude-unifi-skill`)? Uninstall the plugins installed from those marketplaces, remove the marketplaces, and +install from `jonasthim` as above. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..31e1c66 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +# Homelab plugins for Claude Code + +Three [Claude Code](https://claude.com/claude-code) plugins for running a homelab, published from one +marketplace named `jonasthim`. Each plugin installs on its own. + +| Plugin | Operates | Needs | +|---|---|---| +| [truenas](truenas.md) | TrueNAS SCALE 25.x: pools, datasets, snapshots, shares, apps, alerts, updates, replication | API key, or SSH to the NAS | +| [proxmox](proxmox.md) | Proxmox VE 9: VMs, containers, snapshots, backups, storage, networking/SDN, HA, access control | API token (SSH optional) | +| [unifi](unifi.md) | UniFi networks: clients, devices, health, networks/VLANs, SSIDs, firewall | Integration API key (Site Manager key and SSH optional) | + +``` +claude plugin marketplace add jonasthim/claude +claude plugin install truenas@jonasthim # and/or proxmox, unifi +``` + +## Pages + +- [Getting started](getting-started.md): install, configure, first checks, update and uninstall +- [TrueNAS](truenas.md), [Proxmox VE](proxmox.md), [UniFi](unifi.md): setup and use per plugin +- [Safety model](safety.md): what Claude does on its own, what it asks first, and how secrets are handled +- [Building a mod](mods.md): add your own function-hook plugin to this marketplace, with a worked example + +Each plugin's own README in [`plugins/`](https://github.com/jonasthim/claude/tree/main/plugins) is the full +reference; these pages summarise it. diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 0000000..1c4fb64 --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,186 @@ +# Building a mod + +A mod is a Claude Code plugin made of **function hooks**: a TypeScript module the engine loads into a session +and calls at its events. Where a skill tells Claude how to do something, a mod changes what the session itself +does: it can intercept or answer tool calls, decide permissions, show a status line entry, a band above the +prompt, a pane or a toast, add a slash command, or change the system prompt. + +This page builds one end to end. The finished example is in +[`docs/examples/homelab-guard`](https://github.com/jonasthim/claude/tree/main/docs/examples/homelab-guard), +and CI validates and tests it. + +> Function hooks are an early-access Claude Code API and may change between releases. The `claude-code` +> types that each Claude Code build writes beside a loaded mod are the reference. + +## The model in one paragraph + +A module exports `register(on, options)`. `on(event, matcher?, hook)` adds a hook, and every hook has the +shape `($, e, next)`: + +- `$` is the engine: `$.ui`, `$.tool`, `$.fs`, `$.clock` and the rest. +- `e` is the event's input. +- `next(e)` runs everything beneath (other plugins, then the engine's own behaviour) and resolves to the result. + +A hook can return `next(e)` to pass, call `next({ ...e, x })` to rewrite, or return its own answer. +`options` holds the values of the `userConfig` fields declared in `plugin.json`. + +## Layout + +A mod in this repository lives beside the other plugins and is listed in the root marketplace: + +``` +plugins// + .claude-plugin/plugin.json name, version, description (and userConfig for settings) + hooks/hooks.json { "modules": ["./register.ts"] } + hooks/register.ts the hooks module + hooks/*.test.ts tests, run by `claude plugin test` + types/index.d.ts only if the mod keeps values in $.state +``` + +Settings and credentials go in `userConfig` fields, never in the code or environment variables, so users set +them in the config menu when they install the mod. + +## Worked example: homelab-guard + +The three plugins already gate destructive calls in their own code. This mod adds one shared extra check on +top: whenever Claude is about to run a command that carries a plugin's "the user agreed" flag +(`tn.py ... --confirm`, `unifi.py ... --yes`) or an HTTP DELETE against Proxmox, the session asks you first, +even if a permission rule would have allowed the command. + +It hooks `tool.check`, the event where the engine decides whether a tool call may run. `next(e)` gives the +verdict from your rules and mode, and the hook may turn an `allow` into an `ask`: + +### 1. Manifest: `.claude-plugin/plugin.json` + +```json +{ + "name": "homelab-guard", + "version": "0.1.0", + "author": { "name": "Jonas Thim" }, + "description": "Asks before any confirmed destructive TrueNAS, Proxmox or UniFi call runs." +} +``` + +### 2. Hook list: `hooks/hooks.json` + +```json +{ "modules": ["./register.ts"] } +``` + +### 3. The module: `hooks/register.ts` + +```ts +import type { Register } from 'claude-code' + +// Commands that carry a plugin's own "I have confirmation" flag, or an HTTP DELETE. +// Each entry: a pattern over the Bash command, then the reason the dialog shows. +// The `s` flag lets `.` cross newlines, so a command split with `\` is matched too. +const GATED: readonly [RegExp, string][] = [ + [/\btn\.py\b.*\s--confirm\b/s, 'a gated TrueNAS call (tn.py --confirm)'], + [/\bunifi\.py\b.*\s--yes\b/s, 'a UniFi write (unifi.py --yes)'], + [/\bpve-api\.sh\s+delete\b/i, 'an HTTP DELETE against Proxmox (pve-api.sh DELETE)'], +] + +export function gatedReason(command: string): string | undefined { + for (const [pattern, reason] of GATED) { + if (pattern.test(command)) return reason + } + return undefined +} + +export const register: Register = on => { + on('tool.check', { tool: 'Bash' }, async ($, e, next) => { + const verdict = await next(e) + const command = (e.input as { command?: unknown }).command + const reason = typeof command === 'string' ? gatedReason(command) : undefined + // Never loosen a deny; turn an allow into an ask for gated commands. + if (reason === undefined || verdict.decision === 'deny') return verdict + return { decision: 'ask', reason: `${$.plugin.name}: ${reason}. Confirm it is what you agreed to.` } + }).catch(() => ({ decision: 'ask', reason: 'homelab-guard failed; confirm this command yourself.' })) +} +``` + +Points worth copying: + +- **It never loosens.** A `deny` from beneath is returned untouched; only an `allow` becomes an `ask`. +- **It fails safe.** A hook that throws is skipped, so a guard would fail open. The `.catch` answers `ask` + in its place. +- **The matcher** `{ tool: 'Bash' }` keeps the hook off every other tool. + +### 4. Tests: `hooks/guard.test.ts` + +In a test, `on` registers hooks beneath the plugin, standing in for the engine. Here they answer `allow` or +`deny`, and `$.tool.check` asks for a verdict without running anything: + +```ts +import { describe, expect, test } from 'claude-code/testing' + +const check = (command: string) => ({ tool: 'Bash', input: { command } }) + +describe('homelab-guard', () => { + test('asks before a confirmed TrueNAS call', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + const verdict = await $.tool.check(check('python3 tn.py call pool.dataset.delete \'["tank/x"]\' --confirm')) + expect(verdict.decision).toBe('ask') + }) + + test('asks before a UniFi write and a lowercase Proxmox delete', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 unifi.py devices restart d1 --yes'))).decision).toBe('ask') + expect((await $.tool.check(check('pve-api.sh delete /nodes/pve1/qemu/100'))).decision).toBe('ask') + }) + + test('asks when the command is split across lines', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 tn.py call pool.dataset.delete \\\n \'["tank/x"]\' --confirm'))).decision).toBe('ask') + expect((await $.tool.check(check('python3 unifi.py wifi delete w1 \\\n --yes'))).decision).toBe('ask') + }) + + test('leaves reads and dry runs alone', async ($, on) => { + on('tool.check', () => ({ decision: 'allow' })) + expect((await $.tool.check(check('python3 tn.py call pool.query'))).decision).toBe('allow') + expect((await $.tool.check(check('python3 unifi.py devices restart d1 --dry-run'))).decision).toBe('allow') + }) + + test('never loosens a deny', async ($, on) => { + on('tool.check', () => ({ decision: 'deny', reason: 'blocked by a rule' })) + expect((await $.tool.check(check('python3 unifi.py wifi delete w1 --yes'))).decision).toBe('deny') + }) +}) +``` + +## Develop and check + +``` +claude plugin validate plugins/ # manifest and module, as the engine will read them +claude plugin test plugins/ # runs hooks/*.test.ts against the engine +claude --plugin-dir plugins/ # load it into a session for one run +``` + +Type-checking: once the engine has loaded the mod it writes `.claude-plugin/types/` and a `tsconfig.json` +beside it, and `tsc -p plugins/` checks the module. Claude Code's plugin-authoring skill can also build a mod with +you in a session, hot-reloading it as you edit, before you copy it here. + +## Publish + +1. Move the mod into `plugins//` and add an entry to `.claude-plugin/marketplace.json`: + + ```json + { "name": "", "source": "./plugins/", "description": "...", "category": "...", "keywords": [] } + ``` + +2. Add a `README.md`, `LICENSE` and `CHANGELOG.md` like the other plugins ([CONTRIBUTING.md](https://github.com/jonasthim/claude/blob/main/CONTRIBUTING.md)). +3. Install it from a terminal session: `/plugin install @jonasthim`. + +## Limits worth knowing + +- A mod's `ask` goes to the session's permission mode. In the default mode that is the dialog. In auto mode + the classifier decides, so a mod like this one adds to the plugins' own gates and does not replace them. + The Proxmox guard hook is a classic PreToolUse hook and always shows the prompt. +- The module runs in its own environment, with no DOM and no Node: everything outside goes through `$`. + +## More ideas + +- **Homelab health band**: a band above the prompt with pool health, cluster quorum and WAN state, refreshed + on a timer (`$.clock`) by running the plugins' read-only commands. +- **Status line**: `$.ui.status()` showing which homelab credentials the session has loaded, without their values. diff --git a/docs/proxmox.md b/docs/proxmox.md new file mode 100644 index 0000000..2cb308f --- /dev/null +++ b/docs/proxmox.md @@ -0,0 +1,81 @@ +# Proxmox VE + +Operates Proxmox VE 9 clusters: inventory and health, VM (`qm`) and container (`pct`) lifecycle, snapshots, +vzdump backups and restores, cloud-init templates, storage, networking and SDN, cluster and HA, users, roles +and ACLs. Access goes through the REST API with an API token; SSH to the nodes is an optional second tier. The +scripts need `bash`, `curl` and `jq`. + +Full reference: [plugins/proxmox/README.md](https://github.com/jonasthim/claude/blob/main/plugins/proxmox/README.md). + +## Create an API token + +Create the token yourself, on a node as root or in the web UI (*Datacenter → Permissions → API Tokens*), not +through Claude: the secret is shown once, and anything Claude runs lands in the transcript. A read-only token +is enough for `/proxmox:status`, `/proxmox:doctor`, listings and diagnostics: + +``` +pveum user add claude@pve -comment "Claude Code" +pveum user token add claude@pve ro -privsep 0 -comment "Claude read-only" +pveum acl modify / -user claude@pve -role PVEAuditor -propagate 1 +``` + +For an operator token that can start, stop, clone, snapshot and back up guests, add: + +``` +pveum acl modify /vms -user claude@pve -role PVEVMAdmin -propagate 1 +pveum acl modify /storage/ -user claude@pve -role PVEDatastoreUser -propagate 1 +pveum acl modify /sdn -user claude@pve -role PVESDNUser -propagate 1 +``` + +Node reboot, network apply and apt need `Sys.PowerMgmt`/`Sys.Modify`, which only the Administrator role +carries (on `/` or `/nodes/`), or the SSH tier. + +## Configure + +| Variable | Required | Purpose | +|---|---|---| +| `PVE_HOST` | yes | `host[:port]` or a URL; `https://` and `:8006` are added when absent | +| `PVE_TOKEN_ID` | yes | `user@realm!tokenid`, for example `claude@pve!ro` | +| `PVE_TOKEN_SECRET` | yes | The token value. Never printed by the scripts | +| `PVE_CA_CERT` | no | The cluster CA: copy `/etc/pve/pve-root-ca.pem` from any node | +| `PVE_INSECURE` | no | `1` disables TLS verification. A last resort you set yourself; Claude never does | +| `PVE_SSH_HOST`, `PVE_SSH_USER`, `PVE_SSH_PORT`, `PVE_SSH_KEY`, `PVE_SSH_OPTS` | no | The SSH tier | + +The full table (timeouts, debug, dry run) is in the plugin README. + +## Commands and what to ask + +| Command | What it does | +|---|---| +| `/proxmox:doctor` | Checks the connection and token; reports what the token can do per ACL path. Run it first | +| `/proxmox:status [node \| vmid \| storage]` | Read-only cluster, node, guest or storage overview | +| `/proxmox:vm ` | QEMU VM: list, status, config, start, shutdown, stop, reboot, reset, suspend, resume, clone, migrate, destroy | +| `/proxmox:ct ` | LXC: list, status, config, create, start, shutdown, stop, reboot, migrate, destroy | +| `/proxmox:snapshot ` | Snapshots | +| `/proxmox:backup ` | vzdump backups, jobs, restores, failure diagnosis | + +Or just ask: "Which VMs are using the most memory?", "Snapshot 101 before I upgrade it", "Why did last night's +backup of 105 fail?". The `pve` skill loads whenever you mention Proxmox, PVE, `qm`, `pct`, vzdump or a VMID. +For a task run end to end, `@agent-proxmox:proxmox-operator` works without asking you anything, so it acts +on a gated step only when your task contains a line such as `CONFIRMED: stop vm 101`; otherwise it returns the +plan. + +## Safety + +Three layers: + +1. **Plan and confirm.** Before a gated action (destroy, stop, rollback, migrate, deletes, node reboot, + network or SDN apply, HA changes, cluster membership, upgrades over SSH) Claude shows a PLAN block and runs + it only after your yes. +2. **Guard hook.** A PreToolUse hook asks for permission on every gated command, even in auto mode. There is + no variable to disable it; disable the plugin to turn it off. +3. **Your token.** A read-only token cannot do damage whatever the model decides. + +Free actions (reads, start/resume, create, clone, config changes without `delete`, snapshot create, backup +runs, staged network objects, user/role create and ACL grants) run without asking. The `pve` skill holds +the authoritative lists. See also the [safety model](safety.md). + +## Troubleshooting + +`/proxmox:doctor` maps each failure to a fix: exit 2 is transport or TLS (set `PVE_CA_CERT`), a 401 is a wrong +or expired token, a 403 names the ACL path and privilege that is missing. diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..f28014b --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,29 @@ +# Safety model + +All three plugins follow the same rules, written down in [CONTRIBUTING.md](https://github.com/jonasthim/claude/blob/main/CONTRIBUTING.md). + +1. **Reads run freely.** Status, listings, logs and health checks never ask. +2. **Destructive or disruptive actions need your yes.** Claude first shows what it will do, to what, the + effect and how to undo it, and waits for your answer in a later message. +3. **A code-level gate backs that up**, so a model mistake alone cannot run the action. +4. **Secrets stay out of the conversation.** Claude never asks for a password or key in chat, never prints + one, and redacts credentials in tool output by default. Credentials come from environment variables you + set. +5. **Least privilege does the rest.** A read-only key or token cannot do damage, whatever the model decides. + Start there. + +## Per plugin + +| | TrueNAS | Proxmox VE | UniFi | +|---|---|---|---| +| Gate | `tn.py` refuses gated methods without `--confirm` | PreToolUse guard hook asks on every gated command, even in auto mode | `unifi.py` refuses every write without `--yes` | +| Preview | | `PVE_DRY_RUN=1` | `--dry-run` | +| Runs without asking | Reads, creates, property updates, snapshots, service restarts, app starts and upgrades | Reads, start/resume, create, clone, config without `delete`, snapshot create, backup runs, staged network objects | Reads only | +| Redaction | On; `--show-secrets` to turn off | Token secret never printed; dry runs mask passwords | On; `--show-secrets` to turn off | +| TLS verification | On; `TRUENAS_VERIFY_SSL=0` to turn off | On; `PVE_CA_CERT` for the cluster CA, `PVE_INSECURE=1` as a last resort | Off by default; `UNIFI_VERIFY_TLS=1` to turn on | +| Read-only credential | `READONLY_ADMIN` user's API key | `PVEAuditor` on `/` | Read-only admin's API key | + +Claude never turns TLS verification off or shows secrets on its own; those are your choices. + +Creating credentials is your step: make API keys and tokens in the web UI or your own shell, since a new +secret is printed once and anything Claude runs lands in the transcript. diff --git a/docs/truenas.md b/docs/truenas.md new file mode 100644 index 0000000..d14f239 --- /dev/null +++ b/docs/truenas.md @@ -0,0 +1,63 @@ +# TrueNAS SCALE + +Operates a TrueNAS SCALE 25.x system: pools, datasets, zvols, snapshots and scrubs; SMB, NFS and iSCSI shares +and ACLs; apps; alerts; services; system updates; replication and cloud sync. One standard-library Python +script (`tn.py`) talks to the middleware over its JSON-RPC websocket API with an API key, or over SSH by +running `midclt` on the NAS. Python 3.10+; no packages to install. + +Full reference: [plugins/truenas/README.md](https://github.com/jonasthim/claude/blob/main/plugins/truenas/README.md). + +## Create an API key + +In the TrueNAS web UI: your user icon (top right) → *API Keys* → *Add*. The key acts as its user: a +`READONLY_ADMIN` user gives a read-only key, a `FULL_ADMIN` user (root, truenas_admin, or any user with that +role) one that can make changes. + +## Configure + +Configure one transport. The API (`ws`) needs `TRUENAS_HOST` and `TRUENAS_API_KEY`; SSH needs +`TRUENAS_SSH_HOST` (or just `TRUENAS_HOST`, used as `root@`). With both configured the API is used. + +| Variable | Needed for | Purpose | +|---|---|---| +| `TRUENAS_HOST` | API (and SSH default host) | `hostname[:port]`, or a full `wss://` URI | +| `TRUENAS_API_KEY` | API | The API key | +| `TRUENAS_USER` | optional | The key's user (switches to `auth.login_ex`) | +| `TRUENAS_SCHEME` | optional | `ws` for an HTTP-only web UI | +| `TRUENAS_VERIFY_SSL` | optional | `0` accepts a self-signed certificate. Set it yourself; Claude never does | +| `TRUENAS_SSH_HOST` | SSH | `[user@]host` for the SSH transport and read-only shell diagnostics (`zpool status`, `docker logs`) | +| `TRUENAS_SSH_OPTS` | optional | Extra `ssh` options, for example `-J me@jump.lan` | +| `TRUENAS_TRANSPORT` | optional | `ws` or `ssh` to force a transport (default: `ws` when an API key is set) | +| `TRUENAS_DEBUG` | optional | `1` includes server tracebacks in errors | +| `TRUENAS_SHOW_SECRETS` | optional | `1` turns off redaction. Only when you need a credential shown | + +The SSH transport runs `midclt` as root (or a user with passwordless sudo), a broader grant than an API key. +Prefer the API, through a port forward if needed (below). + +## What to ask + +Just talk about your NAS: + +- "Is my NAS healthy? Anything I should worry about?" +- "Snapshot tank/photos, then make the photos SMB share read-only for guests." +- "Jellyfin is stuck deploying, figure out why and fix it." +- "Set up an NFS export of tank/media for 192.168.1.0/24, read-only." +- "Which snapshots on tank/backup are older than 60 days?" + +## Safety + +- `tn.py call` refuses methods that delete, roll back, export, wipe, lock, reboot, update the OS, change ACLs + or permissions, stop apps or services, or change users, network and boot configuration, unless `--confirm` + is passed. Claude adds it only after you agree to that specific operation. +- Reads, creates, property updates, snapshots, service restarts and app starts, redeploys and upgrades run + without asking. +- Passwords, secrets, tokens, private keys, dataset keys and credential attributes come back as `[REDACTED]`. + +See also the [safety model](safety.md). + +## Troubleshooting + +- **Web port blocked from where Claude runs, SSH works**: forward it, `ssh -fN -L 8443::443 `, + and set `TRUENAS_HOST=127.0.0.1:8443`. Expect a certificate name mismatch; Claude reports it and waits for + your decision instead of disabling verification. +- **Unsupported**: TrueNAS CORE and SCALE 24.10 (different API protocol). diff --git a/docs/unifi.md b/docs/unifi.md new file mode 100644 index 0000000..468bbd6 --- /dev/null +++ b/docs/unifi.md @@ -0,0 +1,65 @@ +# UniFi + +Operates a Ubiquiti UniFi network through the official UniFi Network Integration API (local, API key) and the +Site Manager API (api.ui.com), with optional read-only SSH diagnostics: client troubleshooting, inventory and +health reports, networks and VLANs, SSIDs, firewall zones and policies, ACLs, DNS, and device actions. The +bundled CLI (`unifi.py`) is standard-library Python 3.8+. + +Needs a UniFi OS console (UDM, UDM Pro/SE, UCG, UDR, Cloud Key G2+) or a self-hosted Network application, +Network 9.4 or newer (10.x for network, SSID and firewall writes), reachable from where Claude Code runs. + +Full reference: [plugins/unifi/README.md](https://github.com/jonasthim/claude/blob/main/plugins/unifi/README.md). + +## Create the keys + +1. **Integration API key**: in the Network app, *Settings → Control Plane → Integrations → API Keys → Create + API Key*. A read-only admin's key gives a read-only plugin; create it as a full admin to allow changes. +2. **Site Manager key** (optional): at [unifi.ui.com](https://unifi.ui.com) → API, for ISP metrics and a + multi-console overview. It is a different key from the Integration key. +3. **SSH** (optional): *Settings → Control Plane → Console → SSH*, and add your public key. + +## Configure + +| Variable | Required | Purpose | +|---|---|---| +| `UNIFI_HOST` | yes | Console IP or hostname | +| `UNIFI_API_KEY` | yes | Integration API key | +| `UNIFI_SITE` | no | Site name, default `default` | +| `UNIFI_CLOUD_API_KEY` | no | Site Manager key (`UNIFI_SITE_MANAGER_API_KEY` also works) | +| `UNIFI_VERIFY_TLS` | no | `1` verifies the console certificate. Off by default because consoles ship self-signed certificates | +| `UNIFI_TIMEOUT` | no | HTTP timeout in seconds, default 20 | +| `UNIFI_CACHE_DIR` | no | Discovery cache, default `~/.cache/unifi-ops` (6 h); `0` disables it | + +## What to ask + +Start with "check my unifi setup". Then, for example: + +- "Why does my laptop keep dropping off wifi?" +- "Give me a health report." +- "What's connected to the office AP?" +- "Create a guest network on VLAN 30 with its own SSID." +- "Restart the hallway AP." + +## Try it without a controller + +``` +export UNIFI_MOCK_DIR=$PWD/plugins/unifi/skills/unifi-ops/evals/fixtures +python3 plugins/unifi/skills/unifi-ops/scripts/unifi.py report health --table +``` + +The fixtures describe a synthetic home site; writes are echoed back and never stored. + +## Safety + +- Reads never prompt. Every write prints the exact request and stops unless `--yes` is given; `--dry-run` + prints it without sending. +- Claude shows one plan per change, calls out the blast radius (dependent SSIDs, zones and policies, uplink + ports, the management network, the gateway), and verifies afterwards. +- WiFi passphrases, RADIUS secrets and voucher codes are redacted unless `--show-secrets` is passed. +- SSH is read-only in the bundled script. + +See also the [safety model](safety.md). + +## Not covered + +UniFi Protect, Access and Talk; the legacy `/api/s/` controller API; several consoles in one session. diff --git a/mods/README.md b/mods/README.md index 614c7bd..0a896a7 100644 --- a/mods/README.md +++ b/mods/README.md @@ -1,35 +1,18 @@ # Mods -A mod is a Claude Code plugin made of function hooks: a TypeScript module the engine loads in place, -which can draw a pane, a band above the prompt or a status line entry, show toasts, register slash -commands, and intercept tool calls. Nothing ships here yet; this file holds the conventions for when -one does. +A mod is a Claude Code plugin made of function hooks: a TypeScript module that can intercept tool calls, +decide permissions, and draw a status line entry, a band above the prompt or a pane. None ship in this +marketplace yet. -## Where a mod goes +**[docs/mods.md](../docs/mods.md)** is the guide: the hook model, the layout, a worked example +([`docs/examples/homelab-guard`](../docs/examples/homelab-guard)) with tests, and how to publish. -A mod is a plugin like the others: `plugins//`, with an entry in the root -`.claude-plugin/marketplace.json`. This folder holds documentation only, so the marketplace stays the -one index. +## Conventions -``` -plugins// - .claude-plugin/plugin.json { "name": "", "version": "0.1.0", "description": "..." } - hooks/hooks.json { "modules": ["./register.tsx"] } - hooks/register.tsx export const register: Register = (on, options) => { ... } - types/index.d.ts only if the mod keeps values in $.state - hooks/*.test.ts behaviour tests -``` - -- Settings and credentials are `userConfig` fields in `plugin.json` and reach the module as `options`, - never environment variables or constants in the code. -- Check with `claude plugin validate plugins/` and `claude plugin test plugins/`. Develop - with `claude --plugin-dir plugins/`. -- Install like any other plugin: `/plugin install @jonasthim`. - -## Candidates - -- **Homelab guard**: one `tool.call` hook that gates destructive `tn.py`, `pve-api.sh` and `unifi.py` - calls the same way and redacts secrets in their output, replacing the per-plugin differences. The - proxmox `guard.sh` command hook stays for clients without mod support. -- **Homelab health**: a band or status line entry showing pool health, cluster quorum and WAN state, - refreshed on a timer. +- A mod lives in `plugins//` like any other plugin and has an entry in the root + `.claude-plugin/marketplace.json`. This folder holds documentation only. +- Settings and credentials are `userConfig` fields in `plugin.json`, never environment variables or + constants in the code. +- It passes `claude plugin validate plugins/` and `claude plugin test plugins/`, with at least one + `*.test.ts` for the behaviour it adds. +- A guard never loosens a decision from beneath it and fails safe (a `.catch` that refuses or asks). diff --git a/plugins/proxmox/CHANGELOG.md b/plugins/proxmox/CHANGELOG.md index 245b231..a2d2cf2 100644 --- a/plugins/proxmox/CHANGELOG.md +++ b/plugins/proxmox/CHANGELOG.md @@ -26,6 +26,7 @@ receive an update. and leaves password login to the user. - `/proxmox:doctor` classifies the token per ACL path instead of treating one node-scoped `Sys.Modify` as cluster-wide admin. +- README free set no longer lists token creation, which is the user's own step. ## 0.2.0 - 2026-10-06 diff --git a/plugins/proxmox/README.md b/plugins/proxmox/README.md index c919899..2c48454 100644 --- a/plugins/proxmox/README.md +++ b/plugins/proxmox/README.md @@ -138,7 +138,8 @@ destroy; stop/reset/shutdown/reboot/suspend of a guest; snapshot rollback/delete ### Free set (runs without confirmation) -all GETs; start/resume; create VM/CT; clone; set config without `delete`; snapshot create; vzdump run without explicit prune; create backup job; HA resource add; SDN/network object create/edit (staged, not applied); storage add/edit; user/role/token create; apt update (refresh); task log reads. +all GETs; start/resume; create VM/CT; clone; set config without `delete`; snapshot create; vzdump run without explicit prune; create backup job; HA resource add; SDN/network object create/edit (staged, not applied); storage add/edit; user/role create and ACL grants; apt update (refresh); task log reads. Token creation is +yours to do, in the web UI or your own shell: the secret is printed once (see Create an API token). The `pve` skill's section 1 is the authoritative copy of both lists.