6 Commits

Author SHA1 Message Date
9339aac5cf notifyr: ntfy send/read client for agents
send (title/priority/tags), read (poll mode, greppable one-liners),
topics (known homelab buses from config). Config with homelab defaults
on first run. Unit tests against httptest; smoked against the real
ntfy on topic agent-tools-test. Spec: doc/tool-parity.md 3.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:30:12 +02:00
e5b1d73c70 doc(tool-parity): live test of agy 1.1.9 — real toolset, schedule limits, buy-before-build notes
All checks were successful
release-tools / build-release (push) Successful in 5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:47:58 +02:00
5131e9f823 doc: tool-parity plan — Gemini CLI vs Claude Code gap analysis + homelab admin tool specs
All checks were successful
release-tools / build-release (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:27:58 +02:00
c2e89eeaa1 ci/tasks/readme: register hitbox-tool
All checks were successful
release-tools / build-release (push) Successful in 36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:12:39 +02:00
0ebaf7b4ce Merge dev/hitbox-tool: hitbox v1 2026-07-14 07:12:05 +02:00
f8924d89f7 hitbox-tool: per-frame collision boxes from sprite sheet alpha -> JSON
- parses the spritec _WxH_CxR naming convention incl. auto-detection of
  integer-upscaled sheets (256x64 named 8x8_4x1 -> 64x64 cells)
- tight alpha bbox per frame with threshold/shrink/pad tuning, row-major
  indices, empty-frame flags; boxes relative to frame origin
- show command draws frames + box outline in the terminal for verification
- go tests: scanning, threshold, shrink/pad clamping, name parsing,
  scale inference, JSON roundtrip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:12:05 +02:00
18 changed files with 1620 additions and 2 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Detektera ändrade tools - name: Detektera ändrade tools
id: changed id: changed
run: | run: |
TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker" TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool"
BEFORE="${{ github.event.before }}" BEFORE="${{ github.event.before }}"
CHANGED="" CHANGED=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \ if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
@@ -58,6 +58,7 @@ jobs:
mesh-tool) BIN=mesht ;; mesh-tool) BIN=mesht ;;
bitmap-font-maker) BIN=fontc ;; bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;; sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;;
*) echo "okänt tool $t"; exit 1 ;; *) echo "okänt tool $t"; exit 1 ;;
esac esac
echo "=== $t ($BIN) ===" echo "=== $t ($BIN) ==="
@@ -86,6 +87,7 @@ jobs:
mesh-tool) BIN=mesht ;; mesh-tool) BIN=mesht ;;
bitmap-font-maker) BIN=fontc ;; bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;; sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;;
esac esac
TAG="$t-latest" TAG="$t-latest"
BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)." BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."

18
.vscode/tasks.json vendored
View File

@@ -49,6 +49,22 @@
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": ["$go"]
}, },
{
"label": "build hitbox-tool",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/hitbox .",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" },
"group": "build",
"problemMatcher": ["$go"]
},
{
"label": "test hitbox-tool",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" },
"group": "test",
"problemMatcher": ["$go"]
},
{ {
"label": "test pixel-sprite-maker", "label": "test pixel-sprite-maker",
"type": "shell", "type": "shell",
@@ -67,7 +83,7 @@
}, },
{ {
"label": "build", "label": "build",
"dependsOn": ["build pixel-sprite-maker", "build mesh-tool", "build bitmap-font-maker", "build sfx-maker"], "dependsOn": ["build pixel-sprite-maker", "build mesh-tool", "build bitmap-font-maker", "build sfx-maker", "build hitbox-tool"],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"group": { "kind": "build", "isDefault": true }, "group": { "kind": "build", "isDefault": true },
"problemMatcher": [] "problemMatcher": []

View File

@@ -12,10 +12,20 @@ agent understands the result without opening an image viewer.
| [`mesh-tool/`](mesh-tool/) | `mesht` | Creates, inspects and edits 3D models (OBJ + STL). ASCII multi-view rendering + measurements (bbox, volume, watertightness) let an agent *see* a model, edit it (scale/rotate/mirror/merge/primitives) and verify the result. | | [`mesh-tool/`](mesh-tool/) | `mesht` | Creates, inspects and edits 3D models (OBJ + STL). ASCII multi-view rendering + measurements (bbox, volume, watertightness) let an agent *see* a model, edit it (scale/rotate/mirror/merge/primitives) and verify the result. |
| [`bitmap-font-maker/`](bitmap-font-maker/) | `fontc` | Turns `.font` text files (pixel glyph grids, proportional widths) into font atlases (PNG + JSON metrics) and renders text strings to PNG or the terminal. | | [`bitmap-font-maker/`](bitmap-font-maker/) | `fontc` | Turns `.font` text files (pixel glyph grids, proportional widths) into font atlases (PNG + JSON metrics) and renders text strings to PNG or the terminal. |
| [`sfx-maker/`](sfx-maker/) | `sfxc` | Synthesizes retro game sound effects (sfxr-style) from `.sfx` text presets to 16-bit WAV: waves, envelope, pitch slides, vibrato, arpeggio, filters. Deterministic, with built-in presets (jump, coin, laser…). | | [`sfx-maker/`](sfx-maker/) | `sfxc` | Synthesizes retro game sound effects (sfxr-style) from `.sfx` text presets to 16-bit WAV: waves, envelope, pitch slides, vibrato, arpeggio, filters. Deterministic, with built-in presets (jump, coin, laser…). |
| [`hitbox-tool/`](hitbox-tool/) | `hitbox` | Scans sprite sheet PNGs and writes per-frame collision boxes as JSON from the alpha channel. Understands the spritec sheet naming convention including upscaled sheets. |
Each tool has its own folder, its own README with the full format/CLI Each tool has its own folder, its own README with the full format/CLI
reference, its own tests and its own dev branch (`dev/<tool>`). reference, its own tests and its own dev branch (`dev/<tool>`).
## Planned: agent-capability & homelab-admin tools
[`doc/tool-parity.md`](doc/tool-parity.md) compares Gemini CLI's
built-in tools with Claude Code's, and specs the CLI tools that close
the gaps (`notifyr`, `giteactl`, `waitfor`, `cronr`, `fleet`,
`envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl`, `fanout`) so
any agent gets the same capabilities via `run_shell_command`. Build
order and rationale live there and in [`doc/plan.md`](doc/plan.md).
## Building ## Building
```bash ```bash

View File

@@ -37,6 +37,26 @@ master/main. Only tools whose folders changed get rebuilt.
- New tools: bitmap-font maker, sound-effect generator (sfxr-style text - New tools: bitmap-font maker, sound-effect generator (sfxr-style text
presets), tiled-map (.tmx) writer. presets), tiled-map (.tmx) writer.
### Agent-capability tools (see [tool-parity.md](tool-parity.md))
Close the gap between Gemini CLI and Claude Code, plus homelab admin
tools grounded in infra-Doc history. Suggested order:
1. `notifyr` — send/read on the existing Pi5 ntfy bus (≈ PushNotification).
2. `giteactl` — Gitea Actions runs, zst CI logs, `wait`/`wait-quiet`
build serialization, releases.
3. `waitfor` — block until a condition holds (≈ Monitor);
`cronr` — systemd-user-timer scheduling (≈ CronCreate/List/Delete).
4. `fleet` — one-shot homelab health snapshot via the read-only
claude-docker wrapper.
5. `envaudit` — compose ↔ `.env` key/inline-secret audit;
`reghelper` — registry catalog + prune *plans*;
`pagepub` — publish HTML/MD report to a URL (≈ Artifact);
`nbcell` — Jupyter cell editing (≈ NotebookEdit);
`wtreectl` — disposable git worktrees.
6. `fanout` — parallel headless-agent orchestration (≈ Workflow); only
on concrete need.
## Open questions ## Open questions
- Versioned releases (`vX.Y.Z` tags per tool) on top of the rolling - Versioned releases (`vX.Y.Z` tags per tool) on top of the rolling

297
doc/tool-parity.md Normal file
View File

@@ -0,0 +1,297 @@
# Tool parity: Google's agent vs Claude Code
Goal: let Google's agent CLI do everything Claude Code can, by building
the missing capabilities as small CLI tools in this repo. The agent
calls them through its shell tool, so every tool follows the house
rules: **text in, verifiable artifacts out**, single static Go binary,
self-explanatory output.
**Which Google agent?** Legacy `gemini-cli` is auth-dead for personal
accounts (verified again 2026-08-05, see infra-Doc
`hosts/brasse-linux01.md`). The real target is **`agy` (Antigravity
CLI)** — live-tested in section 2, and its toolset differs from the
old Gemini CLI docs. Section 1's table is kept for reference since
Gemini CLI still exists in API-key mode.
A design rule that fell out of the live test: **a dedicated binary
beats ad-hoc shell because of approval prefixes.** agy (like Claude
Code) allowlists commands by prefix — `notifyr …` can be approved once
and forever, while every hand-rolled `for i in $(seq …); do curl …`
loop is a unique string that needs fresh human approval. Small stable
CLIs are therefore not just convenience: they are what makes
unattended agent operation possible at all.
Sources: Gemini CLI tools reference (<https://geminicli.com/docs/reference/tools/>),
Claude Code's toolset as of 2026-08, live probing of `agy` 1.1.9.
## 1. Already at parity — nothing to build
| Capability | Claude Code | Gemini CLI |
|---|---|---|
| Read/write/edit files | `Read` / `Write` / `Edit` | `read_file` / `write_file` / `replace` |
| Find files / search text / list dirs | `Glob` / `Grep` | `glob` / `grep_search` / `list_directory`, plus `read_many_files` |
| Shell, incl. background processes | `Bash` (+ background tasks) | `run_shell_command` (+ background processes) |
| Web | `WebFetch` / `WebSearch` | `web_fetch` / `google_web_search` |
| Ask the user a structured question | `AskUserQuestion` | `ask_user` |
| Plan mode | `EnterPlanMode` / `ExitPlanMode` | `enter_plan_mode` / `exit_plan_mode` |
| Skills / slash commands | `Skill` (`.claude/skills`) | `activate_skill` (`.gemini/skills`) |
| Persistent memory | file-based memory dir | `save_memory` (simpler, but exists) |
| Todo/task tracking | `TaskCreate`/`TaskUpdate`/… | `write_todos`, `tracker_*` (experimental) |
| MCP servers + resources | MCP tools, `ListMcpResources`/`ReadMcpResource` | MCP tools, `list_mcp_resources`/`read_mcp_resource` |
| Subagents | `Agent` (background, custom types) | subagents (experimental) — weaker, see `fanout` below |
Not worth replicating (harness-internal to Claude Code, no value as a
CLI): `ToolSearch`, `EndConversation`, `ReportFindings`,
`ShareOnboardingGuide`, `DesignSync`, remote cloud execution.
## 2. Live test 2026-08-05: `agy` (Antigravity CLI 1.1.9)
Tested interactively in a tmux session (Google AI Pro account, model
Gemini 3.6 Flash). Its 19 built-in tools, self-enumerated:
`ask_permission`, `ask_question`, `define_subagent`, `generate_image`,
`grep_search`, `invoke_subagent`, `list_dir`, `list_permissions`,
`manage_subagents`, `manage_task`, `multi_replace_file_content`,
`read_url_content`, `replace_file_content`, `run_command`, `schedule`,
`search_web`, `send_message`, `view_file`, `write_to_file`.
What this changes vs the old Gemini CLI picture:
- **agy has real subagents** (`invoke_subagent`/`define_subagent`/
`manage_subagents` + `send_message`) and background-task management
(`manage_task`). → `fanout` demoted further; probably never needed.
- **agy has `schedule`** — one-shot timer or cron expression that wakes
the agent with a prompt (same idea as Claude's `ScheduleWakeup`).
Confirmed limits, from its schema: it cannot run commands itself,
it is **in-memory and dies with the session**, and it cannot reach
the phone. → `cronr` (persistent systemd timers) and `notifyr` are
still needed; `schedule` complements them within a session.
- **agy has `generate_image`** — a *reverse* gap: Claude Code has no
native image generation. Nothing to build; just worth knowing.
- No MCP-resource tools, no memory tool and no glob in its toolset
(grep/list_dir cover finding files).
Behavior tests run in a scratch arena:
| Test | Result |
|---|---|
| Enumerate tools | Clean list of 19 (above) |
| Edit a text file | Worked, auto-approved in trusted folder |
| Edit a Jupyter cell, keep `.ipynb` valid | **Passed** — it wrote a `python3 -c` json script rather than text-replacing. Notebook stayed valid. Cost: a per-command approval each time → `nbcell` demoted to nice-to-have (stable prefix + no ad-hoc python). |
| Wait for a file to appear | Worked via a hand-rolled `for … sleep 1` shell loop — a unique command string needing fresh approval. → exactly the `waitfor` case. |
| Asked agy which CLI tools *it* wants for the homelab | Its list: ntfy client, `tea` (Gitea CLI), `skopeo`/`crane` (registry), `ofelia`/cron daemon, `ctop`-style fleet status — near-1:1 with section 4, and it independently made the approval-prefix argument. |
**Buy before build:** agy's suggestions overlap with off-the-shelf
tools. Evaluate first: `tea` (official Gitea CLI — but it does not read
the Pi5's zst action logs and has no `wait-quiet`, which stay
`giteactl`'s reason to exist, possibly as a thin layer *on top of*
`tea`), `skopeo`/`crane` (cover most of `reghelper` — remaining value
is prune *plans* and size summaries), `ctop` (interactive TUI, not
agent-friendly output — `fleet` still wins for agents).
## 3. Gaps → tools to build
Ordered by expected value. Each becomes its own folder + binary +
README, per repo convention.
### 3.1 `cronr` — scheduled/recurring runs *(Claude: `CronCreate`/`CronList`/`CronDelete`, `/loop`)*
agy's built-in `schedule` dies with the session (see section 2).
`cronr` manages **systemd user timers** so an agent can create
recurring or one-shot jobs that survive session exit and reboot
(including "run this prompt every morning" via `agy -p …`).
```
cronr add nightly-ci-check --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"'
cronr add once-reboot-check --at "2026-08-06 03:00" --cmd '…' # one-shot
cronr list # name, schedule, next run, last result
cronr logs nightly-ci-check # journalctl for the unit
cronr rm nightly-ci-check
```
Output prints the generated unit files so the result is verifiable.
No daemon of its own — systemd does the running.
### 3.2 `waitfor` — block until a condition holds *(Claude: `Monitor`)*
Turns "poll every N seconds" into a single blocking tool call, so the
agent doesn't burn turns polling.
```
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
waitfor --cmd "ssh pi5 docker ps --format '{{.Names}}'" --matches 'gitea' --timeout 10m
waitfor … --then 'notifyr send --msg "gitea is back up"'
```
Exit 0 = condition met, exit 3 = timeout; last output is printed either
way. `--then` runs a command on success (composes with `notifyr`).
### 3.3 `notifyr` — push notifications, send **and read** *(Claude: `PushNotification`)*
The ntfy server **already runs on the Pi5** and is the house-wide
notification bus (topics like `Info`, `pi5-server-fel`, `ci-fel` — see
infra-Doc `services/observability.md`). `notifyr` is a thin client so
every agent uses it the same way:
```
notifyr send --topic ci-fel --title "Build failed" --msg "agent-tools arm64 test: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # poll mode: what has alerted lately?
```
`read` (ntfy's `?poll=1&since=…`) is the underrated half: it lets an
agent *check what the infra has been complaining about* before/after a
change. Config (`~/.config/notifyr/config.json`): server URL + token.
### 3.4 `pagepub` — publish an HTML/Markdown report to a URL *(Claude: `Artifact`)*
Claude Code can publish reports as web pages; Gemini cannot. `pagepub`
rsyncs a file to a static-file host on the Pi5 (nginx container behind
NPM, e.g. `pages.brasse-pc.eu`) and prints the stable URL.
```
pagepub publish report.html --slug ci-report → https://pages.brasse-pc.eu/ci-report/
pagepub publish notes.md --slug pi5-audit # .md rendered to HTML with built-in template
pagepub list | rm <slug>
```
Requires the static host to exist first (small infra task; goes in
infra-Doc + NPM proxy host via `npmctl`).
### 3.5 `nbcell` — Jupyter notebook editing *(Claude: `NotebookEdit`)*
`.ipynb` is JSON that's miserable to edit via `replace`. `nbcell`
exposes cells as text:
```
nbcell list nb.ipynb # index, type, first line, exec count
nbcell show nb.ipynb 3 # cell source (and outputs with --outputs)
nbcell edit nb.ipynb 3 --from-file cell.py
nbcell add nb.ipynb --at 4 --type code --from-file new.py
nbcell rm nb.ipynb 7
```
### 3.6 `wtreectl` — disposable git worktrees *(Claude: worktree isolation for agents)*
Claude Code can give each subagent an isolated git worktree. `wtreectl`
does the same for any agent:
```
wtreectl new [--branch dev/foo] # prints the new worktree path
wtreectl list
wtreectl clean # removes worktrees with no changes
```
Lets two agent sessions work in the same repo without trampling each
other.
### 3.7 `fanout` — parallel subagent orchestration *(Claude: `Workflow`, `Agent`)* — roadmap, not agreed
Runs N prompts as parallel headless agent processes (`gemini -p` /
`claude -p`) with a concurrency cap, collecting each result as JSON in
an output dir. A poor man's `Workflow`:
```
fanout run jobs.json --max 3 --out results/
```
Heavier than the other tools and overlaps with agent-helm's territory —
park until there's a concrete need.
## 4. Suggested tools from infra history
Grounded in what the agent has already been doing per infra-Doc
(`maintenance-and-gaps.md`, `services/source-control-and-deploy.md`,
`services/observability.md`, the per-service "operational quick-ref"
blocks). These help **any** agent (Claude or Gemini) administer the
fleet, and they respect the read-only sudo policy
(`ssh/claude-sudo-policy.md`): everything below is read-or-notify;
mutations still go through Björn's supervised tmux flow.
### 4.1 `giteactl` — Gitea repos, Actions runs and CI logs
The biggest recurring friction. Today: CI status is polled ad hoc, and
logs for private repos are only readable as zst files under
`/srv/storage1/gitea/actions_log/…` on the Pi5. Wraps the Gitea REST +
Actions API:
```
giteactl runs <repo> [--limit 5] # status, branch, duration
giteactl log <repo> <run> [--job N] # fetches + decompresses the zst log
giteactl wait <repo> [--timeout 30m] # block until latest run finishes; exit 0 = green
giteactl wait-quiet [--max-active 1] # block until ≤N heavy builds are running
giteactl release <repo> [<tag>] # rolling-release assets + checksums
```
`wait-quiet` encodes the hard-learned rule "serialize pushes — >2 heavy
builds take the Pi5 down": `giteactl wait-quiet && git push`.
Composes with `waitfor`/`notifyr`.
### 4.2 `fleet` — one-shot health snapshot of the whole homelab
Every service doc ends with the same hand-rolled loop over
`ssh pi5-claude sudo claude-docker ps/inspect …`. `fleet` does that
loop once, properly:
```
fleet status # containers (state, image, restarts), disk/mergerfs fill %, failed systemd units
fleet status --host brasse-linux01
fleet checks # Uptime Kuma monitor states + last ntfy alerts (via notifyr read)
```
Read-only by construction (claude-docker wrapper + sudo allowlist), so
it needs no new permissions. Output is a stable text table an agent can
diff between runs.
### 4.3 `envaudit` — compose ↔ `.env` key auditor
Grounded in a real incident (`${STORAGE1}` undefined in `/srv/.env`
bad mount, rollback) and in maintenance-and-gaps' inline-secret
findings:
```
envaudit check /srv/dockge-staks --env /srv/.env
→ UNDEFINED ${STORAGE1} used by media-stack/compose.yaml
→ INLINE LDAP_ADMIN_PASSWORD hardcoded in openldap/compose.yaml (should live in .env)
→ UNUSED OLD_API_KEY defined but referenced nowhere
```
Pure text analysis of compose files — safe to run anywhere, catches the
two failure classes that have actually happened.
### 4.4 `reghelper` — docker-registry catalog & hygiene
The LAN registry (`192.168.0.19:5000`) has no UI, no auth and no
cleanup story, and the backup plan explicitly wants it slimmed:
```
reghelper ls # catalog + tags + image sizes
reghelper tags <image>
reghelper prune-plan --keep 2 # prints the delete+GC commands (does NOT run them)
```
`prune-plan` deliberately only *prints* the mutation commands for the
supervised tmux flow — same pattern as the sudo policy.
### 4.5 Backup status reader — once Backrest/restic exists
future-plans.md has the whole Backrest+restic design chosen but
unbuilt. When it lands, a `fleet backups` subcommand (last snapshot age
per source, repo size, last check result) closes the loop — an agent
can then *verify* backups instead of trusting them. Not a separate
tool; park under `fleet`.
### Cross-reference
`/home/brasse/repos/dify-agent-tools/` already has a specced-but-unbuilt
set of FastAPI tools (file/image sorting, face recognition, ST-card
export…) for the Dify platform. Different runtime (HTTP tools vs CLI
binaries), same philosophy — don't duplicate those here.
## 5. Suggested build order
1. `notifyr` — smallest, everything else composes with it, ntfy already runs.
2. `giteactl` — removes the biggest daily friction (CI logs + build serialization).
3. `waitfor` + `cronr` — turns both agents into unattended operators.
4. `fleet` — replaces the hand-rolled health loops in every runbook.
5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed.
6. `fanout` — only if a concrete multi-agent need shows up.

66
hitbox-tool/README.md Normal file
View File

@@ -0,0 +1,66 @@
# hitbox-tool (`hitbox`)
Scans **sprite sheet PNGs** and writes per-frame **collision boxes as
JSON**, computed from the alpha channel. Closes the loop with
[`pixel-sprite-maker`](../pixel-sprite-maker/): render a sheet with
`spritec`, scan it with `hitbox`, and your game gets both graphics and
hitboxes without a human drawing rectangles. Go, zero dependencies,
single static binary.
## Build
```bash
# Arch/Garuda: sudo pacman -S go
cd hitbox-tool
go build -o build/hitbox . # or the VS Code task "build hitbox-tool"
go test ./...
```
## Usage
```bash
# spritec-named sheets need no flags — layout is parsed from the name,
# and integer upscales are auto-detected (a _8x8_4x1 sheet that is
# 256x64 px was rendered at --scale 8, so cells are 64x64):
hitbox scan walk_8x8_4x1.png -o walk.hitbox.json
hitbox scan boss.png --cell 32x32 -o boss.json # explicit frame size
hitbox scan portrait.png # whole image = one frame, JSON to stdout
hitbox show walk_8x8_4x1.png --frame 2 # verify visually in the terminal
# tuning:
--threshold 128 # ignore faint pixels (alpha < 128)
--shrink 1 # tighter, more forgiving hitboxes (n px per side)
--pad 2 # generous hitboxes (e.g. pickups)
```
## Output
```json
{
"image": "walk_8x8_4x1.png",
"cellW": 8, "cellH": 8, "cols": 4, "rows": 1,
"alphaThreshold": 1,
"frames": [
{ "index": 0, "col": 0, "row": 0, "empty": false,
"box": { "x": 1, "y": 0, "w": 5, "h": 8 } },
{ "index": 1, "col": 1, "row": 0, "empty": true, "box": null }
]
}
```
- Boxes are **relative to each frame's top-left corner**.
- Frame order is row-major (`index = row * cols + col`), matching
spritec sheets.
- Cells with no solid pixels get `"empty": true`.
In game code:
```
hit = px >= frameX + box.x && px < frameX + box.x + box.w
&& py >= frameY + box.y && py < frameY + box.y + box.h
```
`hitbox show` draws each frame with `#` for solid pixels and `+` for the
box outline, so an agent can verify the result without an image viewer.

View File

@@ -0,0 +1,58 @@
{
"image": "examples/walk_8x8_4x1.png",
"cellW": 64,
"cellH": 64,
"cols": 4,
"rows": 1,
"alphaThreshold": 1,
"frames": [
{
"index": 0,
"col": 0,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 1,
"col": 1,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 2,
"col": 2,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
},
{
"index": 3,
"col": 3,
"row": 0,
"empty": false,
"box": {
"x": 8,
"y": 0,
"w": 40,
"h": 64
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

3
hitbox-tool/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool
go 1.24

View File

@@ -0,0 +1,230 @@
// Package hitbox computes per-frame collision boxes from sprite sheet
// images by scanning the alpha channel.
package hitbox
import (
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
)
// Box is a rectangle relative to its frame's top-left corner.
type Box struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// Frame is the scan result for one sheet cell.
type Frame struct {
Index int `json:"index"`
Col int `json:"col"`
Row int `json:"row"`
Empty bool `json:"empty"`
Box *Box `json:"box"` // nil when Empty
}
// Sheet is the full scan result; the JSON deliverable.
type Sheet struct {
Image string `json:"image"`
CellW int `json:"cellW"`
CellH int `json:"cellH"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Threshold int `json:"alphaThreshold"`
Frames []Frame `json:"frames"`
}
// layoutRe matches the spritec sheet naming convention:
// <base>_<cellW>x<cellH>_<cols>x<rows>.<ext>
var layoutRe = regexp.MustCompile(`_(\d+)x(\d+)_(\d+)x(\d+)\.[A-Za-z]+$`)
// LayoutFromName extracts cell size and grid from a spritec-style file
// name. ok is false when the name doesn't follow the convention.
func LayoutFromName(path string) (cellW, cellH, cols, rows int, ok bool) {
m := layoutRe.FindStringSubmatch(filepath.Base(path))
if m == nil {
return 0, 0, 0, 0, false
}
cellW, _ = strconv.Atoi(m[1])
cellH, _ = strconv.Atoi(m[2])
cols, _ = strconv.Atoi(m[3])
rows, _ = strconv.Atoi(m[4])
return cellW, cellH, cols, rows, true
}
// InferScale detects integer-upscaled sheets: when the image is exactly
// s times bigger than the name-declared layout (both axes, s >= 1), the
// real cell size is cell*s. Returns 0 when the layout doesn't fit.
func InferScale(imgW, imgH, cellW, cellH, cols, rows int) int {
baseW, baseH := cellW*cols, cellH*rows
if baseW <= 0 || baseH <= 0 || imgW%baseW != 0 || imgH%baseH != 0 {
return 0
}
s := imgW / baseW
if s < 1 || imgH/baseH != s {
return 0
}
return s
}
// LoadPNG reads a PNG image from disk.
func LoadPNG(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("%s: %w (only PNG is supported — sheets need an alpha channel)", path, err)
}
return img, nil
}
// Scan computes a tight box per frame: the smallest rectangle covering
// every pixel with alpha >= threshold. shrink/pad (in pixels) contract or
// expand each box afterwards, clamped to the cell.
func Scan(img image.Image, name string, cellW, cellH, threshold, shrink, pad int) (*Sheet, error) {
b := img.Bounds()
if cellW <= 0 || cellH <= 0 {
cellW, cellH = b.Dx(), b.Dy() // whole image = one frame
}
if b.Dx()%cellW != 0 || b.Dy()%cellH != 0 {
return nil, fmt.Errorf("image is %dx%d which is not divisible by cell %dx%d",
b.Dx(), b.Dy(), cellW, cellH)
}
if threshold < 1 || threshold > 255 {
return nil, fmt.Errorf("alpha threshold %d out of range 1-255", threshold)
}
cols, rows := b.Dx()/cellW, b.Dy()/cellH
sheet := &Sheet{
Image: name, CellW: cellW, CellH: cellH,
Cols: cols, Rows: rows, Threshold: threshold,
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
fr := Frame{Index: row*cols + col, Col: col, Row: row}
minX, minY := cellW, cellH
maxX, maxY := -1, -1
for y := 0; y < cellH; y++ {
for x := 0; x < cellW; x++ {
_, _, _, a := img.At(b.Min.X+col*cellW+x, b.Min.Y+row*cellH+y).RGBA()
if int(a>>8) >= threshold {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
}
if maxX < 0 {
fr.Empty = true
} else {
box := Box{X: minX, Y: minY, W: maxX - minX + 1, H: maxY - minY + 1}
box = adjust(box, shrink-pad, cellW, cellH)
fr.Box = &box
}
sheet.Frames = append(sheet.Frames, fr)
}
}
return sheet, nil
}
// adjust contracts the box by delta px on every side (negative delta
// expands), clamped to the cell and to a minimum size of 1x1.
func adjust(b Box, delta, cellW, cellH int) Box {
b.X += delta
b.Y += delta
b.W -= 2 * delta
b.H -= 2 * delta
if b.X < 0 {
b.W += b.X
b.X = 0
}
if b.Y < 0 {
b.H += b.Y
b.Y = 0
}
if b.X+b.W > cellW {
b.W = cellW - b.X
}
if b.Y+b.H > cellH {
b.H = cellH - b.Y
}
if b.W < 1 {
b.W = 1
if b.X > cellW-1 {
b.X = cellW - 1
}
}
if b.H < 1 {
b.H = 1
if b.Y > cellH-1 {
b.Y = cellH - 1
}
}
return b
}
// WriteJSON encodes the sheet as indented JSON.
func (s *Sheet) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(s)
}
// Ascii draws one frame with '#' for solid pixels and '+' for the box
// outline, so results can be verified in a terminal.
func Ascii(img image.Image, s *Sheet, index, threshold int) string {
if index < 0 || index >= len(s.Frames) {
return "(no such frame)\n"
}
fr := s.Frames[index]
b := img.Bounds()
out := make([]rune, 0, (s.CellW+1)*s.CellH)
onBoxEdge := func(x, y int) bool {
if fr.Box == nil {
return false
}
bx := fr.Box
inX := x >= bx.X && x < bx.X+bx.W
inY := y >= bx.Y && y < bx.Y+bx.H
edgeX := x == bx.X || x == bx.X+bx.W-1
edgeY := y == bx.Y || y == bx.Y+bx.H-1
return (inX && inY) && (edgeX || edgeY)
}
for y := 0; y < s.CellH; y++ {
for x := 0; x < s.CellW; x++ {
_, _, _, a := img.At(b.Min.X+fr.Col*s.CellW+x, b.Min.Y+fr.Row*s.CellH+y).RGBA()
solid := int(a>>8) >= threshold
switch {
case solid && onBoxEdge(x, y):
out = append(out, '#')
case solid:
out = append(out, '#')
case onBoxEdge(x, y):
out = append(out, '+')
default:
out = append(out, '.')
}
}
out = append(out, '\n')
}
return string(out)
}

View File

@@ -0,0 +1,137 @@
package hitbox
import (
"bytes"
"encoding/json"
"image"
"image/color"
"testing"
)
// sheet4 builds a 2x1 sheet of 4x4 cells: frame 0 has a 2x2 blob at
// (1,1); frame 1 is empty.
func sheet4() image.Image {
img := image.NewNRGBA(image.Rect(0, 0, 8, 4))
for y := 1; y <= 2; y++ {
for x := 1; x <= 2; x++ {
img.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
}
}
return img
}
func TestScanTightBox(t *testing.T) {
s, err := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
if err != nil {
t.Fatal(err)
}
if s.Cols != 2 || s.Rows != 1 || len(s.Frames) != 2 {
t.Fatalf("layout %dx%d frames=%d", s.Cols, s.Rows, len(s.Frames))
}
f0 := s.Frames[0]
if f0.Empty || f0.Box == nil {
t.Fatal("frame 0 should have a box")
}
if *f0.Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
t.Errorf("frame 0 box = %+v", *f0.Box)
}
f1 := s.Frames[1]
if !f1.Empty || f1.Box != nil {
t.Errorf("frame 1 should be empty, got %+v", f1)
}
}
func TestScanWholeImageDefault(t *testing.T) {
s, err := Scan(sheet4(), "x.png", 0, 0, 1, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(s.Frames) != 1 || s.CellW != 8 || s.CellH != 4 {
t.Errorf("whole-image scan: %+v", s)
}
}
func TestScanErrors(t *testing.T) {
if _, err := Scan(sheet4(), "x.png", 3, 4, 1, 0, 0); err == nil {
t.Error("non-divisible cell size should error")
}
if _, err := Scan(sheet4(), "x.png", 4, 4, 0, 0, 0); err == nil {
t.Error("threshold 0 should error")
}
}
func TestShrinkAndPad(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 255})
}
}
s, _ := Scan(img, "x.png", 4, 4, 1, 1, 0) // shrink 1
if *s.Frames[0].Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
t.Errorf("shrunk box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 4, 4, 1, 0, 3) // pad clamps to cell
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 4, H: 4}) {
t.Errorf("padded box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 4, 4, 1, 10, 0) // over-shrink -> min 1x1
if s.Frames[0].Box.W < 1 || s.Frames[0].Box.H < 1 {
t.Errorf("over-shrunk box = %+v", *s.Frames[0].Box)
}
}
func TestThreshold(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 2, 1))
img.SetNRGBA(0, 0, color.NRGBA{0, 0, 0, 100})
img.SetNRGBA(1, 0, color.NRGBA{0, 0, 0, 200})
s, _ := Scan(img, "x.png", 2, 1, 150, 0, 0)
if *s.Frames[0].Box != (Box{X: 1, Y: 0, W: 1, H: 1}) {
t.Errorf("threshold box = %+v", *s.Frames[0].Box)
}
s, _ = Scan(img, "x.png", 2, 1, 50, 0, 0)
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 2, H: 1}) {
t.Errorf("low-threshold box = %+v", *s.Frames[0].Box)
}
}
func TestLayoutFromName(t *testing.T) {
w, h, c, r, ok := LayoutFromName("/tmp/walk_8x8_4x1.png")
if !ok || w != 8 || h != 8 || c != 4 || r != 1 {
t.Errorf("parsed %d %d %d %d ok=%v", w, h, c, r, ok)
}
if _, _, _, _, ok := LayoutFromName("plain.png"); ok {
t.Error("plain.png should not match")
}
}
func TestInferScale(t *testing.T) {
// walk_8x8_4x1.png rendered at scale 8 -> 256x64
if s := InferScale(256, 64, 8, 8, 4, 1); s != 8 {
t.Errorf("scale = %d, want 8", s)
}
if s := InferScale(32, 8, 8, 8, 4, 1); s != 1 {
t.Errorf("unscaled = %d, want 1", s)
}
if s := InferScale(250, 64, 8, 8, 4, 1); s != 0 {
t.Errorf("non-divisible = %d, want 0", s)
}
if s := InferScale(256, 32, 8, 8, 4, 1); s != 0 {
t.Errorf("axis mismatch = %d, want 0", s)
}
}
func TestJSONShape(t *testing.T) {
s, _ := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
var buf bytes.Buffer
if err := s.WriteJSON(&buf); err != nil {
t.Fatal(err)
}
var back Sheet
if err := json.Unmarshal(buf.Bytes(), &back); err != nil {
t.Fatalf("invalid json: %v", err)
}
if back.Frames[0].Box.W != 2 {
t.Errorf("json roundtrip box = %+v", back.Frames[0].Box)
}
}

204
hitbox-tool/main.go Normal file
View File

@@ -0,0 +1,204 @@
// hitbox scans sprite sheet PNGs and writes per-frame collision boxes
// as JSON, using the alpha channel to find each frame's solid pixels.
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool/hitbox"
)
var version = "dev"
const usage = `hitbox - collision box annotator for sprite sheets
Usage:
hitbox scan <sheet.png> [flags] compute per-frame boxes -> JSON
hitbox show <sheet.png> [flags] draw frames + boxes in the terminal
hitbox version
Scan flags:
--cell <WxH> frame size, e.g. 8x8. Default: parsed from the
spritec naming convention <name>_<W>x<H>_<C>x<R>.png;
if neither is given the whole image is one frame.
--threshold <n> alpha 1-255 that counts as solid (default 1)
--shrink <n> contract every box by n px per side (forgiving hits)
--pad <n> expand every box by n px per side
-o <path> write JSON here (default: stdout)
Show flags: --cell, --threshold, plus
--frame <n> only this frame index (default: all)
Boxes are relative to each frame's top-left corner. Frame indices are
row-major (index = row * cols + col), matching spritec sheets.
In game code: hit if (px,py) inside (frameX + box.x, frameY + box.y,
box.w, box.h).
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "scan":
cmdScan(os.Args[2:])
case "show":
cmdShow(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("hitbox", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'hitbox help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
// parseCell resolves the frame size from --cell, or from the spritec
// naming convention (auto-detecting integer upscales: a sheet named
// _8x8_4x1 that is 256x64 px was rendered at scale 8, so cells are 64x64).
func parseCell(spec, path string, imgW, imgH int) (int, int, error) {
if spec != "" {
parts := strings.SplitN(strings.ToLower(spec), "x", 2)
if len(parts) == 2 {
w, err1 := strconv.Atoi(parts[0])
h, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil && w > 0 && h > 0 {
return w, h, nil
}
}
return 0, 0, fmt.Errorf("--cell %q must look like 8x8", spec)
}
if w, h, cols, rows, ok := hitbox.LayoutFromName(path); ok {
if s := hitbox.InferScale(imgW, imgH, w, h, cols, rows); s > 1 {
fmt.Fprintf(os.Stderr, "hitbox: image is %dx the named layout — using %dx%d cells\n", s, w*s, h*s)
return w * s, h * s, nil
}
return w, h, nil
}
return 0, 0, nil // whole image = one frame
}
func cmdScan(args []string) {
fs := flag.NewFlagSet("scan", flag.ExitOnError)
cell := fs.String("cell", "", "frame size WxH")
threshold := fs.Int("threshold", 1, "solid alpha 1-255")
shrink := fs.Int("shrink", 0, "contract boxes n px per side")
pad := fs.Int("pad", 0, "expand boxes n px per side")
out := fs.String("o", "", "output JSON path (default stdout)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("scan takes exactly one PNG file")
}
path := fs.Arg(0)
img, err := hitbox.LoadPNG(path)
if err != nil {
die("%v", err)
}
b := img.Bounds()
cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy())
if err != nil {
die("%v", err)
}
sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, *shrink, *pad)
if err != nil {
die("%v", err)
}
if *out == "" {
if err := sheet.WriteJSON(os.Stdout); err != nil {
die("%v", err)
}
return
}
f, err := os.Create(*out)
if err != nil {
die("%v", err)
}
defer f.Close()
if err := sheet.WriteJSON(f); err != nil {
die("%v", err)
}
solid := 0
for _, fr := range sheet.Frames {
if !fr.Empty {
solid++
}
}
fmt.Printf("%s (%d frames of %dx%d, %d with pixels)\n",
*out, len(sheet.Frames), sheet.CellW, sheet.CellH, solid)
}
func cmdShow(args []string) {
fs := flag.NewFlagSet("show", flag.ExitOnError)
cell := fs.String("cell", "", "frame size WxH")
threshold := fs.Int("threshold", 1, "solid alpha 1-255")
frame := fs.Int("frame", -1, "frame index (default: all)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("show takes exactly one PNG file")
}
path := fs.Arg(0)
img, err := hitbox.LoadPNG(path)
if err != nil {
die("%v", err)
}
b := img.Bounds()
cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy())
if err != nil {
die("%v", err)
}
sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, 0, 0)
if err != nil {
die("%v", err)
}
for _, fr := range sheet.Frames {
if *frame >= 0 && fr.Index != *frame {
continue
}
if fr.Empty {
fmt.Printf("frame %d (col %d, row %d): empty\n\n", fr.Index, fr.Col, fr.Row)
continue
}
fmt.Printf("frame %d (col %d, row %d): box x=%d y=%d w=%d h=%d\n",
fr.Index, fr.Col, fr.Row, fr.Box.X, fr.Box.Y, fr.Box.W, fr.Box.H)
fmt.Print(hitbox.Ascii(img, sheet, fr.Index, *threshold))
fmt.Println()
}
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "hitbox: "+format+"\n", a...)
os.Exit(1)
}

62
notifyr/README.md Normal file
View File

@@ -0,0 +1,62 @@
# notifyr — ntfy client for agents
Send and **read** notifications on the homelab's ntfy bus
(`ntfy.brasse-pc.eu`) with one stable command prefix, so any agent can
alert a human — and check what the infrastructure has been complaining
about — without hand-rolled `curl` loops that each need a fresh
approval. Closes the `PushNotification` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.3.
## Usage
```
notifyr send --msg "text" [--topic T] [--title X]
[--priority min|low|default|high|urgent] [--tags a,b]
notifyr read [--topic T] [--since 10m|2h|all] [--limit N]
notifyr topics # known topics + which one is the default
notifyr version
```
Examples:
```bash
notifyr send --topic ci-fel --title "Build failed" \
--msg "agent-tools arm64 test: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # what has alerted lately?
notifyr read --limit 5 # newest 5 on the default topic
```
`read` uses ntfy's poll mode (`/json?poll=1&since=…`) and prints one
greppable line per message:
```
2026-08-07 00:29:40 [high] (Build failed) agent-tools arm64 test: FAIL #warning
```
Exit codes: 0 ok, 1 error (bad config, server unreachable, invalid
priority…). `read` with zero messages is **not** an error.
## Config
`~/.config/notifyr/config.json`, created with homelab defaults on the
first run (`NOTIFYR_CONFIG` overrides the path):
```json
{
"server": "https://ntfy.brasse-pc.eu",
"token": "",
"default_topic": "claude",
"topics": { "ci-fel": "failed Gitea Actions builds", "…": "…" }
}
```
`topics` is informational — it feeds `notifyr topics` so an agent can
pick the right bus without reading infra-Doc first. Edit freely.
## Build & test
```bash
go test ./...
go build -o build/notifyr .
```

3
notifyr/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/notifyr
go 1.24

132
notifyr/main.go Normal file
View File

@@ -0,0 +1,132 @@
// notifyr sends and reads notifications on the homelab's ntfy bus, so
// any agent can alert a human and check recent infra alerts the same
// way. See doc/tool-parity.md §3.3.
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/notifyr/notify"
)
var version = "dev"
const usage = `notifyr - ntfy client for agents (send and read notifications)
Usage:
notifyr send --msg "text" [--topic T] [--title X]
[--priority min|low|default|high|urgent] [--tags a,b]
notifyr read [--topic T] [--since 10m|2h|all] [--limit N]
notifyr topics list known topics (from the config)
notifyr version
Config: ~/.config/notifyr/config.json (created on first run; override
path with NOTIFYR_CONFIG). Holds server URL, optional token, the
default topic and the known-topics table.
Examples:
notifyr send --topic ci-fel --title "Build failed" --msg "agent-tools arm64: FAIL" --priority high
notifyr read --topic pi5-server-fel --since 2h # what has alerted lately?
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
cfgPath := notify.ConfigPath()
switch os.Args[1] {
case "send":
cmdSend(cfgPath, os.Args[2:])
case "read":
cmdRead(cfgPath, os.Args[2:])
case "topics":
cmdTopics(cfgPath)
case "version", "--version", "-v":
fmt.Println("notifyr", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'notifyr help'", os.Args[1])
}
}
func cmdSend(cfgPath string, args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
topic := fs.String("topic", "", "topic (default: default_topic from config)")
title := fs.String("title", "", "notification title")
msg := fs.String("msg", "", "message text (required)")
priority := fs.String("priority", "", "min|low|default|high|urgent or 1-5")
tags := fs.String("tags", "", "comma-separated tags/emoji shortcodes")
fs.Parse(args)
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
if *topic == "" {
*topic = cfg.DefaultTopic
}
var tagList []string
if *tags != "" {
tagList = strings.Split(*tags, ",")
}
if err := notify.New(cfg.Server, cfg.Token).Send(*topic, *title, *msg, *priority, tagList); err != nil {
die("%v", err)
}
fmt.Printf("sent to %s/%s\n", cfg.Server, *topic)
}
func cmdRead(cfgPath string, args []string) {
fs := flag.NewFlagSet("read", flag.ExitOnError)
topic := fs.String("topic", "", "topic (default: default_topic from config)")
since := fs.String("since", "12h", "how far back: 10m, 2h, unix timestamp or all")
limit := fs.Int("limit", 0, "print at most N (newest) messages")
fs.Parse(args)
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
if *topic == "" {
*topic = cfg.DefaultTopic
}
msgs, err := notify.New(cfg.Server, cfg.Token).Read(*topic, *since, *limit)
if err != nil {
die("%v", err)
}
if len(msgs) == 0 {
fmt.Printf("no messages on %s since %s\n", *topic, *since)
return
}
for _, m := range msgs {
fmt.Println(notify.Format(m))
}
}
func cmdTopics(cfgPath string) {
cfg, err := notify.LoadConfig(cfgPath)
if err != nil {
die("%v", err)
}
names := make([]string, 0, len(cfg.Topics))
for n := range cfg.Topics {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
mark := " "
if n == cfg.DefaultTopic {
mark = "* "
}
fmt.Printf("%s%-18s %s\n", mark, n, cfg.Topics[n])
}
fmt.Fprintln(os.Stderr, "(* = default topic)")
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "notifyr: "+format+"\n", args...)
os.Exit(1)
}

78
notifyr/notify/config.go Normal file
View File

@@ -0,0 +1,78 @@
package notify
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Config is ~/.config/notifyr/config.json, created with homelab
// defaults on first run. NOTIFYR_CONFIG overrides the path.
type Config struct {
Server string `json:"server"`
Token string `json:"token"`
DefaultTopic string `json:"default_topic"`
Topics map[string]string `json:"topics"` // known topics -> what they carry (informational)
}
func DefaultConfig() *Config {
return &Config{
Server: "https://ntfy.brasse-pc.eu",
Token: "",
DefaultTopic: "claude",
Topics: map[string]string{
"claude": "agents' direct notes to Björn",
"agent-helm": "agent-helm events (question waiting, session died)",
"Info": "*arr system events",
"media-hamtningar": "media grabbed for download",
"media-nytt": "new media landed in Jellyfin",
"pi5-server": "server maintenance (reboots, watchtower)",
"pi5-server-fel": "server problems: failed units, disk space",
"ci-fel": "failed Gitea Actions builds",
"monitoring": "Uptime Kuma up/down alerts",
},
}
}
func ConfigPath() string {
if p := os.Getenv("NOTIFYR_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "notifyr", "config.json")
}
// LoadConfig reads the config, creating it with defaults on first run.
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := SaveConfig(path, cfg); err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "notifyr: created %s\n", path)
return cfg, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o600)
}

165
notifyr/notify/notify.go Normal file
View File

@@ -0,0 +1,165 @@
// Package notify is a thin client for a ntfy server: publish
// notifications and poll past ones, so agents can both alert humans
// and check what the infrastructure has been complaining about.
package notify
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Message is one ntfy message as returned by the /json poll endpoint.
type Message struct {
ID string `json:"id"`
Time int64 `json:"time"`
Event string `json:"event"`
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
}
// Client talks to one ntfy server.
type Client struct {
Server string // e.g. https://ntfy.brasse-pc.eu
Token string // optional bearer token
HTTP *http.Client
}
func New(server, token string) *Client {
return &Client{
Server: strings.TrimRight(server, "/"),
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) auth(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
}
// ValidPriority reports whether p is a priority ntfy accepts.
func ValidPriority(p string) bool {
switch p {
case "", "1", "2", "3", "4", "5", "min", "low", "default", "high", "max", "urgent":
return true
}
return false
}
// Send publishes a message to a topic.
func (c *Client) Send(topic, title, msg, priority string, tags []string) error {
if topic == "" {
return fmt.Errorf("no topic given (flag --topic or default_topic in the config)")
}
if msg == "" {
return fmt.Errorf("empty message")
}
if !ValidPriority(priority) {
return fmt.Errorf("invalid priority %q (use min|low|default|high|urgent or 1-5)", priority)
}
req, err := http.NewRequest("POST", c.Server+"/"+url.PathEscape(topic), strings.NewReader(msg))
if err != nil {
return err
}
c.auth(req)
if title != "" {
req.Header.Set("Title", title)
}
if priority != "" {
req.Header.Set("Priority", priority)
}
if len(tags) > 0 {
req.Header.Set("Tags", strings.Join(tags, ","))
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
return fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return nil
}
// Read polls past messages from a topic. since accepts ntfy's formats:
// a duration ("10m", "2h"), a unix timestamp, a message id, or "all".
func (c *Client) Read(topic, since string, limit int) ([]Message, error) {
if topic == "" {
return nil, fmt.Errorf("no topic given (flag --topic or default_topic in the config)")
}
if since == "" {
since = "all"
}
u := fmt.Sprintf("%s/%s/json?poll=1&since=%s", c.Server, url.PathEscape(topic), url.QueryEscape(since))
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
c.auth(req)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var out []Message
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var m Message
if err := json.Unmarshal([]byte(line), &m); err != nil {
continue // tolerate junk lines; poll output is one JSON object per line
}
if m.Event != "message" {
continue
}
out = append(out, m)
}
if err := sc.Err(); err != nil {
return nil, err
}
if limit > 0 && len(out) > limit {
out = out[len(out)-limit:] // keep the newest
}
return out, nil
}
// Format renders a message as one stable, greppable line.
func Format(m Message) string {
ts := time.Unix(m.Time, 0).Format("2006-01-02 15:04:05")
prio := ""
switch {
case m.Priority >= 4:
prio = " [high]"
case m.Priority > 0 && m.Priority <= 2:
prio = " [low]"
}
title := ""
if m.Title != "" {
title = " (" + m.Title + ")"
}
tags := ""
if len(m.Tags) > 0 {
tags = " #" + strings.Join(m.Tags, " #")
}
return fmt.Sprintf("%s%s%s %s%s", ts, prio, title, strings.ReplaceAll(m.Message, "\n", " ⏎ "), tags)
}

View File

@@ -0,0 +1,135 @@
package notify
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func TestSendSetsHeadersAndBody(t *testing.T) {
var got *http.Request
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
body = string(b[:n])
fmt.Fprint(w, `{"id":"x"}`)
}))
defer srv.Close()
c := New(srv.URL, "tok123")
err := c.Send("ci-fel", "Build failed", "arm64 test: FAIL", "high", []string{"warning", "ci"})
if err != nil {
t.Fatal(err)
}
if got.URL.Path != "/ci-fel" {
t.Errorf("path = %q", got.URL.Path)
}
if body != "arm64 test: FAIL" {
t.Errorf("body = %q", body)
}
for hdr, want := range map[string]string{
"Title": "Build failed",
"Priority": "high",
"Tags": "warning,ci",
"Authorization": "Bearer tok123",
} {
if v := got.Header.Get(hdr); v != want {
t.Errorf("%s = %q, want %q", hdr, v, want)
}
}
}
func TestSendValidation(t *testing.T) {
c := New("http://example.invalid", "")
if err := c.Send("", "", "hello", "", nil); err == nil {
t.Error("empty topic accepted")
}
if err := c.Send("t", "", "", "", nil); err == nil {
t.Error("empty message accepted")
}
if err := c.Send("t", "", "hello", "banana", nil); err == nil {
t.Error("bogus priority accepted")
}
}
func TestReadParsesPollOutput(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("poll") != "1" {
t.Errorf("poll param missing: %s", r.URL.RawQuery)
}
if r.URL.Query().Get("since") != "2h" {
t.Errorf("since = %q", r.URL.Query().Get("since"))
}
fmt.Fprintln(w, `{"id":"a","time":1754500000,"event":"message","topic":"t","message":"first","priority":3}`)
fmt.Fprintln(w, `{"id":"b","time":1754500060,"event":"keepalive","topic":"t"}`)
fmt.Fprintln(w, `not json at all`)
fmt.Fprintln(w, `{"id":"c","time":1754500120,"event":"message","topic":"t","title":"T","message":"second","priority":5,"tags":["x"]}`)
}))
defer srv.Close()
msgs, err := New(srv.URL, "").Read("t", "2h", 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2 (keepalive + junk filtered)", len(msgs))
}
if msgs[0].Message != "first" || msgs[1].Title != "T" {
t.Errorf("unexpected messages: %+v", msgs)
}
}
func TestReadLimitKeepsNewest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for i := 1; i <= 5; i++ {
fmt.Fprintf(w, "{\"id\":\"%d\",\"time\":%d,\"event\":\"message\",\"topic\":\"t\",\"message\":\"m%d\"}\n", i, 1754500000+i, i)
}
}))
defer srv.Close()
msgs, err := New(srv.URL, "").Read("t", "all", 2)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 || msgs[0].Message != "m4" || msgs[1].Message != "m5" {
t.Errorf("limit should keep the newest: %+v", msgs)
}
}
func TestFormatIsOneGreppableLine(t *testing.T) {
line := Format(Message{Time: 1754500000, Title: "Backup", Message: "done\nall good", Priority: 4, Tags: []string{"ok"}})
if strings.Contains(line, "\n") {
t.Error("format must be a single line")
}
for _, want := range []string{"[high]", "(Backup)", "done", "#ok"} {
if !strings.Contains(line, want) {
t.Errorf("line %q missing %q", line, want)
}
}
}
func TestConfigRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg, err := LoadConfig(path) // first run creates defaults
if err != nil {
t.Fatal(err)
}
if cfg.Server == "" || cfg.DefaultTopic == "" {
t.Error("defaults incomplete")
}
cfg.DefaultTopic = "elsewhere"
if err := SaveConfig(path, cfg); err != nil {
t.Fatal(err)
}
again, err := LoadConfig(path)
if err != nil {
t.Fatal(err)
}
if again.DefaultTopic != "elsewhere" {
t.Error("saved change did not persist")
}
}