9 Commits

Author SHA1 Message Date
78c7b32eb2 cronr: recurring/one-shot jobs as systemd user timers
add (schedule validated by systemd-analyze, units + script printed),
list (next elapse + last result), run, logs, rm. Command stored as a
script so quoting never meets ExecStart; PATH includes ~/.local/bin
so agent tools resolve. cronr-* namespace guards foreign units.
Smoked live: add -> run -> journal -> rm. Spec: doc/tool-parity.md 3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:42:57 +02:00
c676dc270a Merge dev/waitfor: waitfor v1 2026-08-07 00:39:58 +02:00
6c8cae885b waitfor: block until a shell condition holds
--cmd exits 0 (+ optional --matches regex) or --timeout; exit 0/3/1,
last output always printed, --then hook composes with notifyr.
Injectable runner for tests + real-shell integration test.
Spec: doc/tool-parity.md 3.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:39:58 +02:00
2c34b66da5 Merge dev/svg-maker: svgc v1 2026-08-07 00:37:51 +02:00
36631c7709 svg-maker: svgc - .svgd text format -> SVG for agent-helm sharing
Line-based DSL (shapes, text, groups, color vars) with strict
validation and line-numbered errors, SVG emitter with visibility
defaults, truecolor terminal preview (same half-block technique as
spritec), info with bbox + outside-canvas warnings. Verified
end-to-end: svgc build -> helmd share -> visible through the hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:37:51 +02:00
9f90d4b8be Merge dev/notifyr: notifyr v1 2026-08-07 00:30:12 +02:00
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
29 changed files with 3466 additions and 0 deletions

View File

@@ -17,6 +17,15 @@ agent understands the result without opening an image viewer.
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>`).
## 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
```bash

54
cronr/README.md Normal file
View File

@@ -0,0 +1,54 @@
# cronr — agent scheduling via systemd user timers
Lets an agent create recurring or one-shot jobs that **survive session
exit and reboot** — what agy's in-memory `schedule` tool and Claude's
in-session wakeups cannot do. No daemon: systemd runs the jobs, cronr
just manages namespaced `cronr-<name>` units. Closes the
`CronCreate/List/Delete` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.1.
## Usage
```
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" # one-shot
cronr list # schedule, next run, last result per job
cronr run <name> # run the job right now (timer untouched)
cronr logs <name> [--lines N] # the job's journal
cronr rm <name> # disable and delete
```
Examples:
```bash
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"'
cronr add backup-ping --schedule "Mon *-*-* 09:00" --cmd 'notifyr send --msg "weekly backup check"'
cronr add once --at "2026-08-10 03:00" --cmd 'systemctl --user restart helmd'
```
## How it works
`add` writes three files and prints all of them, so the result is
fully verifiable:
- `~/.local/share/cronr/<name>.sh` — the command, verbatim (shell
quoting never meets systemd's ExecStart parsing)
- `~/.config/systemd/user/cronr-<name>.service` — oneshot, with
`~/.local/bin` on PATH so agent tools (notifyr, svgc, agy…) resolve
- `~/.config/systemd/user/cronr-<name>.timer``OnCalendar=…`,
`Persistent=true` for recurring jobs (missed runs fire on next boot)
Schedules are validated by `systemd-analyze calendar` before anything
is written — you get systemd's own error text plus the computed next
elapse. `list`/`rm` only ever see `cronr-*` units, so other services
are untouchable by construction.
`cronr run <name>` starts the service immediately — handy for testing
a job before trusting the schedule.
## Build & test
```bash
go test ./...
go build -o build/cronr .
```

3
cronr/go.mod Normal file
View File

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

300
cronr/main.go Normal file
View File

@@ -0,0 +1,300 @@
// cronr schedules recurring or one-shot jobs as systemd user timers,
// so an agent can set up work that survives session exit and reboot.
// No daemon of its own — systemd does the running. See
// doc/tool-parity.md §3.1.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit"
)
var version = "dev"
const usage = `cronr - agent-friendly scheduling via systemd user timers
Usage:
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot
cronr list all cronr jobs: schedule, next run, last result
cronr run <name> run the job now (does not touch the timer)
cronr logs <name> [--lines N] journal for the job
cronr rm <name> disable and delete the job
cronr version
Schedule examples (systemd OnCalendar; validated with systemd-analyze):
"*-*-* 07:00" every morning at 07:00
"Mon *-*-* 09:00" mondays 09:00
"*:0/15" every 15 minutes
The job's command is stored as a script in ~/.local/share/cronr/ and
runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy)
work as in a login shell. Everything cronr creates is namespaced
cronr-<name> — it never touches other units.
Example:
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"'
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "add":
cmdAdd(os.Args[2:])
case "list":
cmdList()
case "run":
requireName(os.Args[2:], "run")
sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service")
fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2])
case "logs":
cmdLogs(os.Args[2:])
case "rm":
cmdRm(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("cronr", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'cronr help'", os.Args[1])
}
}
func unitDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user")
}
func scriptDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "cronr")
}
func cmdAdd(args []string) {
fs := flag.NewFlagSet("add", flag.ExitOnError)
schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs")
at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`)
cmd := fs.String("cmd", "", "shell command the job runs (required)")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("add needs exactly one job name")
}
name := pos[0]
if !unit.ValidName(name) {
die("invalid job name %q (letters, digits, - and _)", name)
}
if *cmd == "" {
die("--cmd is required")
}
if (*schedule == "") == (*at == "") {
die("give exactly one of --schedule (recurring) or --at (one-shot)")
}
spec := *schedule
oneshot := false
if *at != "" {
var err error
spec, err = unit.AtToCalendar(*at)
if err != nil {
die("%v", err)
}
oneshot = true
}
// systemd itself is the authority on calendar specs
if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil {
die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out)))
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil {
die("job %s already exists (cronr rm %s first)", name, name)
}
scriptPath := filepath.Join(scriptDir(), name+".sh")
if err := os.MkdirAll(scriptDir(), 0o755); err != nil {
die("%v", err)
}
if err := os.MkdirAll(unitDir(), 0o755); err != nil {
die("%v", err)
}
writes := []struct{ path, content string }{
{scriptPath, unit.Script(*cmd)},
{filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)},
{filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)},
}
for _, w := range writes {
mode := os.FileMode(0o644)
if strings.HasSuffix(w.path, ".sh") {
mode = 0o755
}
if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil {
die("%v", err)
}
}
sh("systemctl", "--user", "daemon-reload")
sh("systemctl", "--user", "enable", "--now", tmrName)
fmt.Printf("job %s created and enabled\n\n", name)
for _, w := range writes {
fmt.Printf("--- %s ---\n%s\n", w.path, w.content)
}
fmt.Print(nextRun(spec))
}
func cmdList() {
matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer"))
if len(matches) == 0 {
fmt.Println("no cronr jobs")
return
}
sort.Strings(matches)
fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT")
for _, m := range matches {
name := unit.JobName(m)
if name == "" {
continue
}
spec := ""
if data, err := os.ReadFile(m); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "OnCalendar=") {
spec = strings.TrimPrefix(line, "OnCalendar=")
}
}
}
next := strings.TrimPrefix(nextRun(spec), "next run: ")
svcName, _ := unit.UnitNames(name)
last := lastResult(svcName)
fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last)
}
}
// nextRun asks systemd-analyze when the spec fires next.
func nextRun(spec string) string {
out, err := exec.Command("systemd-analyze", "calendar", spec).Output()
if err != nil {
return "next run: ?\n"
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Next elapse:") {
return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n"
}
}
return "next run: never (already elapsed?)\n"
}
func lastResult(svcName string) string {
out, err := exec.Command("systemctl", "--user", "show", svcName,
"-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output()
if err != nil {
return "?"
}
status, when := "?", ""
for _, line := range strings.Split(string(out), "\n") {
if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok {
status = v
}
if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok {
when = v
}
}
if when == "" {
return "never ran"
}
if status == "0" {
return "ok (" + when + ")"
}
return "exit " + status + " (" + when + ")"
}
func cmdLogs(args []string) {
fs := flag.NewFlagSet("logs", flag.ExitOnError)
lines := fs.Int("lines", 50, "number of journal lines")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("logs needs exactly one job name")
}
svcName, _ := unit.UnitNames(pos[0])
c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager")
c.Stdout, c.Stderr = os.Stdout, os.Stderr
c.Run()
}
func cmdRm(args []string) {
requireName(args, "rm")
name := args[0]
if !unit.ValidName(name) {
die("invalid job name %q", name)
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil {
die("no such job %s", name)
}
sh("systemctl", "--user", "disable", "--now", tmrName)
for _, p := range []string{
filepath.Join(unitDir(), tmrName),
filepath.Join(unitDir(), svcName),
filepath.Join(scriptDir(), name+".sh"),
} {
os.Remove(p)
}
sh("systemctl", "--user", "daemon-reload")
fmt.Printf("job %s removed\n", name)
}
func requireName(args []string, cmd string) {
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
die("%s needs a job name", cmd)
}
}
// sh runs a command, dying with its output on failure — every call
// here is a systemctl whose failure should stop the operation.
func sh(name string, args ...string) {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []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 eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...)
os.Exit(1)
}

96
cronr/unit/unit.go Normal file
View File

@@ -0,0 +1,96 @@
// Package unit generates the systemd user units cronr manages. Pure
// text generation — systemd does the scheduling, cronr owns no daemon.
package unit
import (
"fmt"
"regexp"
"strings"
)
// Prefix namespaces everything cronr creates so list/rm can never
// touch units it does not own.
const Prefix = "cronr-"
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// ValidName reports whether a job name is safe for unit/file names.
func ValidName(name string) bool {
return len(name) <= 64 && nameRe.MatchString(name)
}
// Script wraps the job command in an executable shell script — the
// unit ExecStart points here, so arbitrary quoting in the command
// never meets systemd's ExecStart parsing.
func Script(cmd string) string {
return "#!/bin/sh\n# generated by cronr - the job's command lives here so systemd\n# unit quoting never mangles it\n" + cmd + "\n"
}
// Service renders the .service unit. PATH gets ~/.local/bin first so
// jobs can call agent tools (notifyr, svgc, agy, ...) like a login
// shell would.
func Service(name, scriptPath string) string {
return fmt.Sprintf(`[Unit]
Description=cronr job %s
[Service]
Type=oneshot
Environment=PATH=%%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/sh %s
`, name, scriptPath)
}
// Timer renders the .timer unit. Recurring jobs get Persistent=true
// (a missed run fires at next boot/login); one-shots do not.
func Timer(name, calendarSpec string, oneshot bool) string {
persistent := "true"
if oneshot {
persistent = "false"
}
return fmt.Sprintf(`[Unit]
Description=cronr timer for %s
[Timer]
OnCalendar=%s
Persistent=%s
[Install]
WantedBy=timers.target
`, name, calendarSpec, persistent)
}
// UnitNames returns the service and timer unit names for a job.
func UnitNames(name string) (service, timer string) {
return Prefix + name + ".service", Prefix + name + ".timer"
}
// JobName extracts the job name from a cronr unit filename, or ""
// if the filename is not cronr's.
func JobName(unitFile string) string {
base := unitFile
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if !strings.HasPrefix(base, Prefix) {
return ""
}
base = strings.TrimPrefix(base, Prefix)
for _, suffix := range []string{".timer", ".service"} {
if strings.HasSuffix(base, suffix) {
return strings.TrimSuffix(base, suffix)
}
}
return ""
}
// AtToCalendar converts "YYYY-MM-DD HH:MM" (or with seconds) to a
// systemd calendar spec for one-shot jobs. systemd accepts the format
// as-is; this just validates the shape early with a helpful error.
var atRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$`)
func AtToCalendar(at string) (string, error) {
if !atRe.MatchString(at) {
return "", fmt.Errorf("--at must be \"YYYY-MM-DD HH:MM[:SS]\", got %q", at)
}
return at, nil
}

84
cronr/unit/unit_test.go Normal file
View File

@@ -0,0 +1,84 @@
package unit
import (
"strings"
"testing"
)
func TestValidName(t *testing.T) {
for _, ok := range []string{"nightly-ci-check", "a", "Job_2", "x1-y2"} {
if !ValidName(ok) {
t.Errorf("%q should be valid", ok)
}
}
for _, bad := range []string{"", "-leading", "has space", "slash/y", "ä", strings.Repeat("x", 65), "dot.name"} {
if ValidName(bad) {
t.Errorf("%q should be invalid", bad)
}
}
}
func TestScriptKeepsCommandVerbatim(t *testing.T) {
cmd := `agy -p "check CI, say 'hi' & notify" | tee /tmp/x`
s := Script(cmd)
if !strings.HasPrefix(s, "#!/bin/sh\n") {
t.Error("missing shebang")
}
if !strings.Contains(s, cmd) {
t.Error("command was mangled")
}
}
func TestServiceUnit(t *testing.T) {
s := Service("nightly", "/home/x/.local/share/cronr/nightly.sh")
for _, want := range []string{
"Type=oneshot",
"ExecStart=/bin/sh /home/x/.local/share/cronr/nightly.sh",
"Environment=PATH=%h/.local/bin",
} {
if !strings.Contains(s, want) {
t.Errorf("service missing %q:\n%s", want, s)
}
}
}
func TestTimerUnit(t *testing.T) {
rec := Timer("nightly", "*-*-* 07:00", false)
if !strings.Contains(rec, "OnCalendar=*-*-* 07:00") || !strings.Contains(rec, "Persistent=true") {
t.Errorf("recurring timer wrong:\n%s", rec)
}
once := Timer("boot", "2026-08-06 03:00", true)
if !strings.Contains(once, "Persistent=false") {
t.Errorf("one-shot timer should not be persistent:\n%s", once)
}
if !strings.Contains(once, "WantedBy=timers.target") {
t.Error("timer missing install section")
}
}
func TestUnitAndJobNames(t *testing.T) {
svc, tmr := UnitNames("nightly")
if svc != "cronr-nightly.service" || tmr != "cronr-nightly.timer" {
t.Errorf("unit names: %s %s", svc, tmr)
}
if JobName("/home/x/.config/systemd/user/cronr-nightly.timer") != "nightly" {
t.Error("JobName failed on full path")
}
if JobName("helmd.service") != "" {
t.Error("foreign unit must not map to a job")
}
}
func TestAtToCalendar(t *testing.T) {
if _, err := AtToCalendar("2026-08-06 03:00"); err != nil {
t.Errorf("valid --at rejected: %v", err)
}
if _, err := AtToCalendar("2026-08-06 03:00:30"); err != nil {
t.Errorf("valid --at with seconds rejected: %v", err)
}
for _, bad := range []string{"imorgon", "03:00", "2026-8-6 03:00", "2026-08-06T03:00"} {
if _, err := AtToCalendar(bad); err == nil {
t.Errorf("bad --at %q accepted", bad)
}
}
}

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
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
- 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.

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")
}
}

82
svg-maker/README.md Normal file
View File

@@ -0,0 +1,82 @@
# svg-maker — `svgc`, vector graphics for agents
Builds SVG images from a line-based text format (`.svgd`) that an
agent can author directly, **verify without a GUI** (terminal preview
+ measurements) and show to a human through agent-helm
(`helmd share out.svg`). Built for "graphical elements": status cards,
diagrams, icons, simple illustrations.
## Usage
```
svgc build <file.svgd> [-o out.svg] [--preview] [--width N]
svgc preview <file.svgd> [--width N] truecolor half-block render
svgc info <file.svgd> counts, colors, bbox, warnings
svgc example annotated example to start from
```
Typical agent flow:
```bash
svgc example > card.svgd # start from the example
# ...edit card.svgd...
svgc build card.svgd --preview # writes card.svg + shows what it looks like
helmd share card.svg --note "status card" # display it in agent-helm
```
`build` prints an info block after writing — element counts, colors,
the drawing's bounding box and **warnings for anything outside the
canvas** — so mistakes surface as text even without the preview.
Errors carry line numbers (`card.svgd: line 7: "four" is not a
number — rect needs: rect <x> <y> <w> <h>`).
## The .svgd format
One element per line, `#` comments, `key=value` attributes last:
```
canvas 240 120 # required: width height
bg #12161f # optional background
def accent #4f9cf9 # named color, use as $accent
rect 10 10 60 40 fill=$accent rx=6
circle 120 40 20 fill=#3fca7c stroke=white stroke-width=2
ellipse 60 90 30 12 fill=gray
line 10 100 190 100 stroke=red width=3
polyline 10,20 30,40 50,10 stroke=white
polygon 20,80 40,60 60,80 fill=#e0a63f
path M10,10 L50,50 Q70,20 90,50 Z stroke=white
text 100 60 "Hello agent-helm" size=14 fill=white anchor=middle bold
group stroke=gray stroke-width=1 # group attrs are inherited
line 0 0 10 10
end
```
| Piece | Notes |
|---|---|
| `canvas w h` | required first; becomes the viewBox and image size |
| `bg color` | background rect |
| `def name color` | color variable; `$name` in any fill/stroke |
| attributes | `fill stroke stroke-width` (alias `width`) `opacity fill-opacity stroke-opacity rx ry anchor size font dash linecap linejoin transform id` + flags `bold italic` |
| colors | `#rgb`, `#rrggbb`, CSS names, `none`; unknown attribute keys are errors |
| `path` | subset `M L H V C Q Z`, absolute + relative, commas or spaces |
| `transform` | raw SVG transform with commas: `transform=rotate(45,50,50)` |
Friendly defaults: `line`/`polyline`/`path` get a visible stroke if
you give none, `polyline` gets `fill=none` — no invisible elements,
no accidental filled blobs.
## Preview fidelity
The preview rasterizes the same shape model the emitter writes:
fills, strokes, opacity and group inheritance are honored. Two
approximations: `rx` rounded corners render square, and **text renders
as its baseline box** (real glyphs are a font problem — check text in
the real render via agent-helm). `info` always reflects true geometry.
## Build & test
```bash
go test ./...
go build -o build/svgc .
```

View File

@@ -0,0 +1,12 @@
# gauge.svgd - example: a small status card
canvas 240 120
bg #12161f
def ok #3fca7c
def frame #262d3a
rect 8 8 224 104 fill=none stroke=$frame stroke-width=2 rx=10
circle 40 60 22 fill=none stroke=$ok stroke-width=6
path M30,60 L38,68 L52,50 stroke=$ok width=5 linecap=round fill=none
text 76 54 "backups" size=13 fill=#8a93a5
text 76 76 "all green" size=17 fill=white bold

3
svg-maker/go.mod Normal file
View File

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

176
svg-maker/main.go Normal file
View File

@@ -0,0 +1,176 @@
// svgc builds SVG images from .svgd text descriptions — vector
// graphics an agent can author, verify (terminal preview + info) and
// share to agent-helm with `helmd share out.svg`.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/svg-maker/svg"
)
var version = "dev"
const usage = `svgc - SVG maker for agents
Usage:
svgc build <file.svgd> [-o out.svg] [--preview] [--width N]
svgc preview <file.svgd> [--width N] draw it in the terminal
svgc info <file.svgd> counts, colors, bbox, warnings
svgc example print an annotated example file
svgc version
The .svgd format ('#' comments, one element per line):
canvas 200 120 required: width height
bg #1a2029 optional background
def accent #4f9cf9 named color, use as $accent
rect 10 10 60 40 fill=$accent rx=6
circle 120 40 20 fill=#3fca7c stroke=white stroke-width=2
ellipse 60 90 30 12 fill=gray
line 10 100 190 100 stroke=red width=3
polyline 10,20 30,40 50,10 stroke=white
polygon 20,80 40,60 60,80 fill=#e0a63f
path M10,10 L50,50 Q70,20 90,50 Z stroke=white
text 100 60 "Hello agent-helm" size=14 fill=white anchor=middle bold
group stroke=gray stroke-width=1 group attrs are inherited
line 0 0 10 10
end
Attributes: fill stroke stroke-width|width opacity fill-opacity
stroke-opacity rx ry anchor size font dash linecap linejoin transform
id, plus flags bold italic. Colors: #rgb #rrggbb, CSS names, none.
Show the result in agent-helm: helmd share out.svg --note "diagram"
`
const exampleFile = `# gauge.svgd - example: a small status card
canvas 240 120
bg #12161f
def ok #3fca7c
def frame #262d3a
rect 8 8 224 104 fill=none stroke=$frame stroke-width=2 rx=10
circle 40 60 22 fill=none stroke=$ok stroke-width=6
path M30,60 L38,68 L52,50 stroke=$ok width=5 linecap=round fill=none
text 76 54 "backups" size=13 fill=#8a93a5
text 76 76 "all green" size=17 fill=white bold
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "example":
fmt.Print(exampleFile)
case "version", "--version", "-v":
fmt.Println("svgc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'svgc help'", os.Args[1])
}
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output file (default: input with .svg)")
preview := fs.Bool("preview", false, "also draw the result in the terminal")
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("build takes exactly one .svgd file")
}
xml, doc := load(pos[0])
dst := *out
if dst == "" {
dst = strings.TrimSuffix(pos[0], filepath.Ext(pos[0])) + ".svg"
}
if err := os.WriteFile(dst, []byte(xml), 0o644); err != nil {
die("%v", err)
}
fmt.Printf("wrote %s (%d bytes)\n", dst, len(xml))
fmt.Print(svg.Info(doc))
if *preview {
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
}
func cmdPreview(args []string) {
fs := flag.NewFlagSet("preview", flag.ExitOnError)
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("preview takes exactly one .svgd file")
}
_, doc := load(pos[0])
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .svgd file")
}
_, doc := load(args[0])
fmt.Print(svg.Info(doc))
}
// load reads and builds a .svgd file (attrs normalized), dying with
// the parser's line-numbered error on failure. Returns the SVG XML
// and the parsed document.
func load(path string) (string, *svg.Doc) {
data, err := os.ReadFile(path)
if err != nil {
die("%v", err)
}
xml, doc, err := svg.Build(string(data))
if err != nil {
die("%s: %v", path, err)
}
return xml, doc
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []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 eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "svgc: "+format+"\n", args...)
os.Exit(1)
}

135
svg-maker/svg/emit.go Normal file
View File

@@ -0,0 +1,135 @@
package svg
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Emit renders the document as an SVG file.
func Emit(d *Doc) string {
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %s %s" width="%s" height="%s">`,
num(d.W), num(d.H), num(d.W), num(d.H))
b.WriteString("\n")
if d.Bg != "" {
fmt.Fprintf(&b, ` <rect width="100%%" height="100%%" fill="%s"/>`+"\n", d.Bg)
}
for _, el := range d.Elems {
emitElem(&b, el, 1)
}
b.WriteString("</svg>\n")
return b.String()
}
func emitElem(b *strings.Builder, el *Elem, depth int) {
ind := strings.Repeat(" ", depth)
attrs := attrString(el.Attrs)
switch el.Kind {
case "rect":
fmt.Fprintf(b, `%s<rect x="%s" y="%s" width="%s" height="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "circle":
fmt.Fprintf(b, `%s<circle cx="%s" cy="%s" r="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), attrs)
case "ellipse":
fmt.Fprintf(b, `%s<ellipse cx="%s" cy="%s" rx="%s" ry="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "line":
fmt.Fprintf(b, `%s<line x1="%s" y1="%s" x2="%s" y2="%s"%s/>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
case "polyline", "polygon":
pts := make([]string, len(el.Points))
for i, p := range el.Points {
pts[i] = num(p[0]) + "," + num(p[1])
}
fmt.Fprintf(b, `%s<%s points="%s"%s/>`+"\n", ind, el.Kind, strings.Join(pts, " "), attrs)
case "path":
fmt.Fprintf(b, `%s<path d="%s"%s/>`+"\n", ind, escape(el.D), attrs)
case "text":
fmt.Fprintf(b, `%s<text x="%s" y="%s"%s>%s</text>`+"\n",
ind, num(el.Nums[0]), num(el.Nums[1]), attrs, escape(el.Text))
case "group":
fmt.Fprintf(b, "%s<g%s>\n", ind, attrs)
for _, kid := range el.Kids {
emitElem(b, kid, depth+1)
}
fmt.Fprintf(b, "%s</g>\n", ind)
}
}
// defaults SVG would otherwise pick that surprise agents: lines and
// paths get a visible stroke if none set; polyline defaults fill=none
// so it doesn't render as a filled blob.
func effectiveAttrs(el *Elem) map[string]string {
out := map[string]string{}
for k, v := range el.Attrs {
out[k] = v
}
switch el.Kind {
case "line", "polyline":
if out["stroke"] == "" {
out["stroke"] = "black"
}
if el.Kind == "polyline" && out["fill"] == "" {
out["fill"] = "none"
}
case "path":
if out["stroke"] == "" && out["fill"] == "" {
out["stroke"] = "black"
out["fill"] = "none"
}
}
return out
}
func attrString(attrs map[string]string) string {
if len(attrs) == 0 {
return ""
}
keys := make([]string, 0, len(attrs))
for k := range attrs {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
fmt.Fprintf(&b, ` %s="%s"`, k, escape(attrs[k]))
}
return b.String()
}
func num(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func escape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// normalize applies the effective attrs (visibility defaults) onto the
// tree before emit/raster so both outputs agree.
func (d *Doc) normalize() {
var walk func(els []*Elem)
walk = func(els []*Elem) {
for _, el := range els {
el.Attrs = effectiveAttrs(el)
if el.Kind == "group" {
walk(el.Kids)
}
}
}
walk(d.Elems)
}
// Build parses src and emits SVG in one step.
func Build(src string) (string, *Doc, error) {
doc, err := Parse(src)
if err != nil {
return "", nil, err
}
doc.normalize()
return Emit(doc), doc, nil
}

143
svg-maker/svg/info.go Normal file
View File

@@ -0,0 +1,143 @@
package svg
import (
"fmt"
"math"
"sort"
"strings"
)
// Info summarizes a parsed document: element counts, colors, the
// drawing's bounding box and warnings (things drawn outside the
// canvas), so an agent can verify a build without looking at pixels.
func Info(d *Doc) string {
counts := map[string]int{}
colors := map[string]bool{}
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
var warnings []string
var walk func(els []*Elem)
walk = func(els []*Elem) {
for _, el := range els {
if el.Kind == "group" {
counts["group"]++
walk(el.Kids)
continue
}
counts[el.Kind]++
for _, key := range []string{"fill", "stroke"} {
if c := el.Attrs[key]; c != "" && c != "none" && c != "transparent" {
colors[c] = true
}
}
x0, y0, x1, y1, ok := bbox(el)
if !ok {
continue
}
minX = math.Min(minX, x0)
minY = math.Min(minY, y0)
maxX = math.Max(maxX, x1)
maxY = math.Max(maxY, y1)
if x1 < 0 || y1 < 0 || x0 > d.W || y0 > d.H {
warnings = append(warnings, fmt.Sprintf("line %d: %s is entirely outside the canvas", el.Line, el.Kind))
} else if x0 < 0 || y0 < 0 || x1 > d.W || y1 > d.H {
warnings = append(warnings, fmt.Sprintf("line %d: %s sticks outside the canvas", el.Line, el.Kind))
}
}
}
walk(d.Elems)
var b strings.Builder
fmt.Fprintf(&b, "canvas: %gx%g", d.W, d.H)
if d.Bg != "" {
fmt.Fprintf(&b, " bg: %s", d.Bg)
}
b.WriteString("\n")
kinds := make([]string, 0, len(counts))
for k := range counts {
kinds = append(kinds, k)
}
sort.Strings(kinds)
total := 0
parts := make([]string, 0, len(kinds))
for _, k := range kinds {
parts = append(parts, fmt.Sprintf("%s:%d", k, counts[k]))
if k != "group" {
total += counts[k]
}
}
fmt.Fprintf(&b, "elements: %d (%s)\n", total, strings.Join(parts, " "))
if len(colors) > 0 {
cl := make([]string, 0, len(colors))
for c := range colors {
cl = append(cl, c)
}
sort.Strings(cl)
fmt.Fprintf(&b, "colors: %s\n", strings.Join(cl, " "))
}
if total > 0 && !math.IsInf(minX, 1) {
fmt.Fprintf(&b, "drawing bbox: %.4g,%.4g .. %.4g,%.4g\n", minX, minY, maxX, maxY)
}
for _, w := range warnings {
fmt.Fprintf(&b, "warning: %s\n", w)
}
return b.String()
}
// bbox computes an element's geometric bounding box (stroke width and
// transforms not included — good enough for out-of-canvas warnings).
func bbox(el *Elem) (x0, y0, x1, y1 float64, ok bool) {
switch el.Kind {
case "rect":
return el.Nums[0], el.Nums[1], el.Nums[0] + el.Nums[2], el.Nums[1] + el.Nums[3], true
case "circle":
cx, cy, r := el.Nums[0], el.Nums[1], el.Nums[2]
return cx - r, cy - r, cx + r, cy + r, true
case "ellipse":
cx, cy, rx, ry := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
return cx - rx, cy - ry, cx + rx, cy + ry, true
case "line":
return math.Min(el.Nums[0], el.Nums[2]), math.Min(el.Nums[1], el.Nums[3]),
math.Max(el.Nums[0], el.Nums[2]), math.Max(el.Nums[1], el.Nums[3]), true
case "polyline", "polygon":
return pointsBBox(el.Points)
case "path":
subs, err := flattenPath(el.D)
if err != nil {
return 0, 0, 0, 0, false
}
var all [][2]float64
for _, sp := range subs {
all = append(all, sp...)
}
return pointsBBox(all)
case "text":
size := attrFloat(el.Attrs, "font-size", 16)
w := 0.6 * size * float64(len([]rune(el.Text)))
x, y := el.Nums[0], el.Nums[1]
switch el.Attrs["text-anchor"] {
case "middle":
x -= w / 2
case "end":
x -= w
}
return x, y - size, x + w, y, true
}
return 0, 0, 0, 0, false
}
func pointsBBox(pts [][2]float64) (x0, y0, x1, y1 float64, ok bool) {
if len(pts) == 0 {
return 0, 0, 0, 0, false
}
x0, y0 = pts[0][0], pts[0][1]
x1, y1 = x0, y0
for _, p := range pts {
x0 = math.Min(x0, p[0])
y0 = math.Min(y0, p[1])
x1 = math.Max(x1, p[0])
y1 = math.Max(y1, p[1])
}
return x0, y0, x1, y1, true
}

430
svg-maker/svg/parse.go Normal file
View File

@@ -0,0 +1,430 @@
// Package svg turns .svgd text descriptions into SVG images an agent
// can verify: build (emit XML), preview (terminal raster) and info
// (measurements + warnings).
package svg
import (
"fmt"
"strconv"
"strings"
)
// Elem is one drawing element. Kind decides which fields matter:
//
// rect Nums: x y w h
// circle Nums: cx cy r
// ellipse Nums: cx cy rx ry
// line Nums: x1 y1 x2 y2
// polyline Points
// polygon Points
// path D (subset: M L H V C Q Z, absolute + relative)
// text Nums: x y, Text
// group Kids (inherits its Attrs to children)
type Elem struct {
Kind string
Nums []float64
Points [][2]float64
D string
Text string
Attrs map[string]string
Line int
Kids []*Elem
}
// Doc is a parsed .svgd file.
type Doc struct {
W, H float64
Bg string
Elems []*Elem
}
// attrKeys maps .svgd attribute names to SVG presentation attributes.
// Friendly aliases keep the format short; unknown keys are errors so
// typos surface at build time instead of becoming invisible SVG.
var attrKeys = map[string]string{
"fill": "fill",
"stroke": "stroke",
"stroke-width": "stroke-width",
"width": "stroke-width", // common shorthand on line/path
"opacity": "opacity",
"fill-opacity": "fill-opacity",
"stroke-opacity": "stroke-opacity",
"rx": "rx",
"ry": "ry",
"anchor": "text-anchor",
"size": "font-size",
"font": "font-family",
"dash": "stroke-dasharray",
"linecap": "stroke-linecap",
"linejoin": "stroke-linejoin",
"transform": "transform",
"id": "id",
}
// flag attributes expand to fixed key/values.
var flagAttrs = map[string][2]string{
"bold": {"font-weight", "bold"},
"italic": {"font-style", "italic"},
}
type parser struct {
defs map[string]string
line int
}
// Parse reads a .svgd document.
func Parse(src string) (*Doc, error) {
p := &parser{defs: map[string]string{}}
doc := &Doc{W: 100, H: 100}
sawCanvas := false
stack := []*[]*Elem{&doc.Elems} // group nesting; top = current container
groupLines := []int{}
for i, raw := range strings.Split(src, "\n") {
p.line = i + 1
line := strings.TrimSpace(raw)
if idx := findComment(line); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
if line == "" {
continue
}
tokens, err := tokenize(line)
if err != nil {
return nil, p.errf("%v", err)
}
kind, rest := tokens[0], tokens[1:]
switch kind {
case "canvas":
nums, _, err := p.numsAndAttrs(rest, 2, "canvas needs: canvas <width> <height>")
if err != nil {
return nil, err
}
if nums[0] <= 0 || nums[1] <= 0 {
return nil, p.errf("canvas size must be positive")
}
doc.W, doc.H = nums[0], nums[1]
sawCanvas = true
case "bg":
if len(rest) != 1 {
return nil, p.errf("bg needs: bg <color>")
}
c, err := p.color(rest[0])
if err != nil {
return nil, err
}
doc.Bg = c
case "def":
if len(rest) != 2 {
return nil, p.errf("def needs: def <name> <color>")
}
c, err := p.color(rest[1])
if err != nil {
return nil, err
}
p.defs[rest[0]] = c
case "group":
attrs, err := p.attrs(rest)
if err != nil {
return nil, err
}
g := &Elem{Kind: "group", Attrs: attrs, Line: p.line}
*stack[len(stack)-1] = append(*stack[len(stack)-1], g)
stack = append(stack, &g.Kids)
groupLines = append(groupLines, p.line)
case "end":
if len(stack) == 1 {
return nil, p.errf("end without group")
}
stack = stack[:len(stack)-1]
groupLines = groupLines[:len(groupLines)-1]
default:
el, err := p.element(kind, rest)
if err != nil {
return nil, err
}
*stack[len(stack)-1] = append(*stack[len(stack)-1], el)
}
}
if len(stack) > 1 {
return nil, fmt.Errorf("line %d: group is never closed (missing end)", groupLines[len(groupLines)-1])
}
if !sawCanvas {
return nil, fmt.Errorf("missing canvas line (canvas <width> <height>)")
}
return doc, nil
}
func (p *parser) element(kind string, rest []string) (*Elem, error) {
el := &Elem{Kind: kind, Line: p.line}
var err error
switch kind {
case "rect":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "rect needs: rect <x> <y> <w> <h>")
case "circle":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 3, "circle needs: circle <cx> <cy> <r>")
case "ellipse":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "ellipse needs: ellipse <cx> <cy> <rx> <ry>")
case "line":
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "line needs: line <x1> <y1> <x2> <y2>")
case "polyline", "polygon":
el.Points, el.Attrs, err = p.pointsAndAttrs(rest, kind)
case "path":
var attrs map[string]string
var dParts []string
split := len(rest)
for i, t := range rest {
if strings.Contains(t, "=") {
split = i
break
}
dParts = append(dParts, t)
}
attrs, err = p.attrs(rest[split:])
el.D, el.Attrs = strings.Join(dParts, " "), attrs
if err == nil && el.D == "" {
err = p.errf("path needs path data (e.g. path M0,0 L10,10 Z)")
}
if err == nil {
if _, ferr := flattenPath(el.D); ferr != nil {
err = p.errf("bad path data: %v", ferr)
}
}
case "text":
if len(rest) < 3 {
return nil, p.errf(`text needs: text <x> <y> "string" [attrs]`)
}
var nums []float64
nums, err = p.nums(rest[:2], 2, "text needs numeric x y")
if err != nil {
return nil, err
}
el.Nums = nums
el.Text = rest[2]
el.Attrs, err = p.attrs(rest[3:])
default:
return nil, p.errf("unknown element %q (rect, circle, ellipse, line, polyline, polygon, path, text, group, def, bg, canvas)", kind)
}
if err != nil {
return nil, err
}
return el, nil
}
func (p *parser) numsAndAttrs(tokens []string, n int, hint string) ([]float64, map[string]string, error) {
if len(tokens) < n {
return nil, nil, p.errf("%s", hint)
}
nums, err := p.nums(tokens[:n], n, hint)
if err != nil {
return nil, nil, err
}
attrs, err := p.attrs(tokens[n:])
return nums, attrs, err
}
func (p *parser) nums(tokens []string, n int, hint string) ([]float64, error) {
if len(tokens) != n {
return nil, p.errf("%s", hint)
}
out := make([]float64, n)
for i, t := range tokens {
v, err := strconv.ParseFloat(t, 64)
if err != nil {
return nil, p.errf("%q is not a number — %s", t, hint)
}
out[i] = v
}
return out, nil
}
func (p *parser) pointsAndAttrs(tokens []string, kind string) ([][2]float64, map[string]string, error) {
var pts [][2]float64
i := 0
for ; i < len(tokens); i++ {
t := tokens[i]
if strings.Contains(t, "=") {
break
}
xy := strings.Split(t, ",")
if len(xy) != 2 {
return nil, nil, p.errf("%s point %q must be x,y", kind, t)
}
x, err1 := strconv.ParseFloat(xy[0], 64)
y, err2 := strconv.ParseFloat(xy[1], 64)
if err1 != nil || err2 != nil {
return nil, nil, p.errf("%s point %q must be numeric x,y", kind, t)
}
pts = append(pts, [2]float64{x, y})
}
min := 2
if kind == "polygon" {
min = 3
}
if len(pts) < min {
return nil, nil, p.errf("%s needs at least %d x,y points", kind, min)
}
attrs, err := p.attrs(tokens[i:])
return pts, attrs, err
}
func (p *parser) attrs(tokens []string) (map[string]string, error) {
out := map[string]string{}
for _, t := range tokens {
if kv, ok := flagAttrs[t]; ok {
out[kv[0]] = kv[1]
continue
}
eq := strings.Index(t, "=")
if eq <= 0 {
return nil, p.errf("expected attribute key=value, got %q", t)
}
key, val := t[:eq], t[eq+1:]
svgKey, ok := attrKeys[key]
if !ok {
return nil, p.errf("unknown attribute %q", key)
}
if val == "" {
return nil, p.errf("attribute %s has empty value", key)
}
if key == "fill" || key == "stroke" {
c, err := p.color(val)
if err != nil {
return nil, err
}
val = c
}
if key == "transform" {
// commas keep the value one token: rotate(45,50,50)
val = strings.ReplaceAll(val, ",", " ")
}
out[svgKey] = val
}
return out, nil
}
// color resolves $vars and validates the value.
func (p *parser) color(v string) (string, error) {
if strings.HasPrefix(v, "$") {
c, ok := p.defs[v[1:]]
if !ok {
return "", p.errf("undefined color variable %s (define it first: def %s #rrggbb)", v, v[1:])
}
return c, nil
}
if v == "none" || v == "transparent" {
return v, nil
}
if strings.HasPrefix(v, "#") {
hexPart := v[1:]
if len(hexPart) != 3 && len(hexPart) != 6 {
return "", p.errf("color %q must be #rgb or #rrggbb", v)
}
for _, r := range hexPart {
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
return "", p.errf("color %q has non-hex digits", v)
}
}
return v, nil
}
for _, r := range v {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
return "", p.errf("color %q must be #hex, a CSS color name, none or $var", v)
}
}
return v, nil // CSS named color — trust the renderer
}
func (p *parser) errf(format string, args ...interface{}) error {
return fmt.Errorf("line %d: %s", p.line, fmt.Sprintf(format, args...))
}
// tokenize splits a line on whitespace, keeping "quoted strings" as
// single tokens (quotes stripped).
func tokenize(line string) ([]string, error) {
var out []string
var cur strings.Builder
inQuote := false
for _, r := range line {
switch {
case r == '"':
if inQuote {
out = append(out, cur.String())
cur.Reset()
inQuote = false
} else {
inQuote = true
}
case !inQuote && (r == ' ' || r == '\t'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if inQuote {
return nil, fmt.Errorf("unterminated quote")
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
if len(out) == 0 {
return nil, fmt.Errorf("empty line")
}
return out, nil
}
// findComment returns the index of a # starting a comment (not inside
// quotes, and not part of a color like #fff).
func findComment(line string) int {
inQuote := false
for i, r := range line {
if r == '"' {
inQuote = !inQuote
}
if r == '#' && !inQuote {
// a color literal follows =, whitespace-then-hex is a comment
if i == 0 {
return 0
}
prev := line[i-1]
if prev == ' ' || prev == '\t' {
// "... # comment" vs "def accent #fff": colors only appear
// after def/bg or key=; a bare hex after a def/bg keyword is
// data, so only treat as comment if it is not valid hex-ish
rest := line[i+1:]
stop := strings.IndexAny(rest, " \t")
word := rest
if stop >= 0 {
word = rest[:stop]
}
if isHexWord(word) {
continue
}
return i
}
}
}
return -1
}
func isHexWord(w string) bool {
if len(w) != 3 && len(w) != 6 {
return false
}
for _, r := range w {
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
return false
}
}
return true
}

195
svg-maker/svg/path.go Normal file
View File

@@ -0,0 +1,195 @@
package svg
import (
"fmt"
"strconv"
"strings"
)
// flattenPath turns a path-data subset (M L H V C Q Z, absolute and
// relative) into one or more polylines, used for validation, preview
// rasterization and measurements. Curves become 16 line segments.
func flattenPath(d string) ([][][2]float64, error) {
tokens, err := pathTokens(d)
if err != nil {
return nil, err
}
var subpaths [][][2]float64
var cur [][2]float64
var x, y, startX, startY float64
i := 0
cmd := ""
need := func(n int) ([]float64, error) {
if i+n > len(tokens) {
return nil, fmt.Errorf("command %s needs %d numbers", cmd, n)
}
out := make([]float64, n)
for j := 0; j < n; j++ {
v, err := strconv.ParseFloat(tokens[i+j], 64)
if err != nil {
return nil, fmt.Errorf("command %s: %q is not a number", cmd, tokens[i+j])
}
out[j] = v
}
i += n
return out, nil
}
flush := func() {
if len(cur) > 1 {
subpaths = append(subpaths, cur)
}
cur = nil
}
for i < len(tokens) {
t := tokens[i]
if len(t) == 1 && strings.ContainsAny(t, "MLHVCQZmlhvcqz") {
cmd = t
i++
if cmd == "Z" || cmd == "z" {
if len(cur) > 0 {
cur = append(cur, [2]float64{startX, startY})
x, y = startX, startY
}
flush()
continue
}
} else if cmd == "" {
return nil, fmt.Errorf("path must start with M/m, got %q", t)
}
// repeated coordinate groups reuse the current command
rel := cmd >= "a" // lowercase = relative
switch strings.ToUpper(cmd) {
case "M":
n, err := need(2)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
}
flush()
x, y = n[0], n[1]
startX, startY = x, y
cur = [][2]float64{{x, y}}
cmd = map[bool]string{true: "l", false: "L"}[rel] // subsequent pairs are implicit lineto
case "L":
n, err := need(2)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
}
x, y = n[0], n[1]
cur = append(cur, [2]float64{x, y})
case "H":
n, err := need(1)
if err != nil {
return nil, err
}
if rel {
n[0] += x
}
x = n[0]
cur = append(cur, [2]float64{x, y})
case "V":
n, err := need(1)
if err != nil {
return nil, err
}
if rel {
n[0] += y
}
y = n[0]
cur = append(cur, [2]float64{x, y})
case "Q":
n, err := need(4)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
n[2] += x
n[3] += y
}
for s := 1; s <= 16; s++ {
t := float64(s) / 16
u := 1 - t
px := u*u*x + 2*u*t*n[0] + t*t*n[2]
py := u*u*y + 2*u*t*n[1] + t*t*n[3]
cur = append(cur, [2]float64{px, py})
}
x, y = n[2], n[3]
case "C":
n, err := need(6)
if err != nil {
return nil, err
}
if rel {
n[0] += x
n[1] += y
n[2] += x
n[3] += y
n[4] += x
n[5] += y
}
for s := 1; s <= 16; s++ {
t := float64(s) / 16
u := 1 - t
px := u*u*u*x + 3*u*u*t*n[0] + 3*u*t*t*n[2] + t*t*t*n[4]
py := u*u*u*y + 3*u*u*t*n[1] + 3*u*t*t*n[3] + t*t*t*n[5]
cur = append(cur, [2]float64{px, py})
}
x, y = n[4], n[5]
default:
return nil, fmt.Errorf("unsupported path command %q (supported: M L H V C Q Z)", cmd)
}
}
flush()
if len(subpaths) == 0 {
return nil, fmt.Errorf("path draws nothing")
}
return subpaths, nil
}
// pathTokens splits path data into command letters and numbers.
func pathTokens(d string) ([]string, error) {
var out []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
for _, r := range d {
switch {
case r == ' ' || r == '\t' || r == ',':
flush()
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
flush()
out = append(out, string(r))
case (r >= '0' && r <= '9') || r == '.' || r == 'e' || r == 'E':
cur.WriteRune(r)
case r == '-' || r == '+':
// sign starts a new number unless it follows an exponent
s := cur.String()
if cur.Len() > 0 && !strings.HasSuffix(s, "e") && !strings.HasSuffix(s, "E") {
flush()
}
cur.WriteRune(r)
default:
return nil, fmt.Errorf("unexpected character %q in path data", r)
}
}
flush()
if len(out) == 0 {
return nil, fmt.Errorf("empty path data")
}
return out, nil
}

332
svg-maker/svg/raster.go Normal file
View File

@@ -0,0 +1,332 @@
package svg
import (
"fmt"
"math"
"strconv"
"strings"
)
// Raster renders the document to a small RGBA grid so an agent can see
// roughly what it drew without an image viewer. Painter's algorithm in
// document order, 2x2 supersampling, honors fill/stroke/opacity.
// Text is approximated by its baseline and an underline-box (real
// glyph rendering is out of scope for a preview).
type Raster struct {
W, H int
Pix [][4]float64 // r g b a, premultiplied-ish blend target
}
type rgba struct{ r, g, b, a float64 }
// RenderGrid rasterizes doc to cols pixels wide (rows follow aspect).
func RenderGrid(d *Doc, cols int) *Raster {
if cols < 8 {
cols = 8
}
if cols > 400 {
cols = 400
}
rows := int(math.Round(float64(cols) * d.H / d.W))
if rows < 1 {
rows = 1
}
if rows > 400 {
rows = 400
}
r := &Raster{W: cols, H: rows, Pix: make([][4]float64, cols*rows)}
bg := parseColor(d.Bg)
if d.Bg == "" {
bg = rgba{0, 0, 0, 0}
}
for i := range r.Pix {
r.Pix[i] = [4]float64{bg.r, bg.g, bg.b, bg.a}
}
scaleX := d.W / float64(cols)
scaleY := d.H / float64(rows)
var walk func(els []*Elem, inherited map[string]string)
walk = func(els []*Elem, inherited map[string]string) {
for _, el := range els {
attrs := merged(inherited, el.Attrs)
if el.Kind == "group" {
walk(el.Kids, attrs)
continue
}
r.drawElem(el, attrs, scaleX, scaleY)
}
}
walk(d.Elems, nil)
return r
}
func merged(parent, child map[string]string) map[string]string {
if parent == nil {
return child
}
out := map[string]string{}
for k, v := range parent {
out[k] = v
}
for k, v := range child {
out[k] = v
}
return out
}
func (r *Raster) drawElem(el *Elem, attrs map[string]string, sx, sy float64) {
fill, hasFill := paint(attrs, "fill", el.Kind)
stroke, hasStroke := paint(attrs, "stroke", el.Kind)
sw := attrFloat(attrs, "stroke-width", 1)
opacity := attrFloat(attrs, "opacity", 1)
inFill, inStroke := coverageFuncs(el, sw, attrs)
if inFill == nil && inStroke == nil {
return
}
for py := 0; py < r.H; py++ {
for px := 0; px < r.W; px++ {
var fillCov, strokeCov float64
for _, dx := range []float64{0.25, 0.75} {
for _, dy := range []float64{0.25, 0.75} {
ux := (float64(px) + dx) * sx
uy := (float64(py) + dy) * sy
if hasFill && inFill != nil && inFill(ux, uy) {
fillCov += 0.25
}
if hasStroke && inStroke != nil && inStroke(ux, uy) {
strokeCov += 0.25
}
}
}
if fillCov > 0 {
r.blend(px, py, fill, fillCov*opacity*attrFloat(attrs, "fill-opacity", 1))
}
if strokeCov > 0 {
r.blend(px, py, stroke, strokeCov*opacity*attrFloat(attrs, "stroke-opacity", 1))
}
}
}
}
// coverageFuncs returns point-inside tests for the fill body and the
// stroke band of an element, in user coordinates.
func coverageFuncs(el *Elem, sw float64, attrs map[string]string) (inFill, inStroke func(x, y float64) bool) {
half := sw / 2
switch el.Kind {
case "rect":
x0, y0, w, h := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
inFill = func(x, y float64) bool { return x >= x0 && x <= x0+w && y >= y0 && y <= y0+h }
inStroke = func(x, y float64) bool {
near := func(v, edge float64) bool { return math.Abs(v-edge) <= half }
inX := x >= x0-half && x <= x0+w+half
inY := y >= y0-half && y <= y0+h+half
return (inX && (near(y, y0) || near(y, y0+h))) || (inY && (near(x, x0) || near(x, x0+w)))
}
case "circle":
cx, cy, rad := el.Nums[0], el.Nums[1], el.Nums[2]
inFill = func(x, y float64) bool { return math.Hypot(x-cx, y-cy) <= rad }
inStroke = func(x, y float64) bool { return math.Abs(math.Hypot(x-cx, y-cy)-rad) <= half }
case "ellipse":
cx, cy, rx, ry := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
if rx <= 0 || ry <= 0 {
return nil, nil
}
norm := func(x, y float64) float64 {
dx, dy := (x-cx)/rx, (y-cy)/ry
return math.Sqrt(dx*dx + dy*dy)
}
inFill = func(x, y float64) bool { return norm(x, y) <= 1 }
inStroke = func(x, y float64) bool {
// approximate band by comparing scaled radial distance
n := norm(x, y)
tol := half / math.Min(rx, ry)
return math.Abs(n-1) <= tol
}
case "line":
seg := [2][2]float64{{el.Nums[0], el.Nums[1]}, {el.Nums[2], el.Nums[3]}}
inStroke = func(x, y float64) bool { return distSeg(x, y, seg[0], seg[1]) <= math.Max(half, 0.5) }
case "polyline", "polygon":
pts := el.Points
inStroke = func(x, y float64) bool {
last := len(pts) - 1
for i := 0; i < last; i++ {
if distSeg(x, y, pts[i], pts[i+1]) <= math.Max(half, 0.5) {
return true
}
}
if el.Kind == "polygon" && distSeg(x, y, pts[last], pts[0]) <= math.Max(half, 0.5) {
return true
}
return false
}
if el.Kind == "polygon" {
inFill = func(x, y float64) bool { return pointInPolygon(x, y, pts) }
}
case "path":
subs, err := flattenPath(el.D)
if err != nil {
return nil, nil
}
inStroke = func(x, y float64) bool {
for _, sp := range subs {
for i := 0; i < len(sp)-1; i++ {
if distSeg(x, y, sp[i], sp[i+1]) <= math.Max(half, 0.5) {
return true
}
}
}
return false
}
inFill = func(x, y float64) bool {
in := false
for _, sp := range subs {
if pointInPolygon(x, y, sp) {
in = !in
}
}
return in
}
case "text":
// baseline box: width ~0.6em per char, height 1em above the
// baseline, honoring text-anchor — real glyphs are out of scope
size := attrFloat(attrs, "font-size", 16)
x0, y0 := el.Nums[0], el.Nums[1]
w := 0.6 * size * float64(len([]rune(el.Text)))
switch attrs["text-anchor"] {
case "middle":
x0 -= w / 2
case "end":
x0 -= w
}
edge := math.Max(size/12, 0.75)
inFill = func(x, y float64) bool {
return x >= x0 && x <= x0+w && y >= y0-size && y <= y0 &&
(y >= y0-edge || y <= y0-size+edge || x <= x0+edge || x >= x0+w-edge)
}
}
return inFill, inStroke
}
func paint(attrs map[string]string, key, kind string) (rgba, bool) {
v := attrs[key]
if v == "" {
if key == "fill" && kind != "line" && kind != "polyline" && kind != "path" {
return rgba{0, 0, 0, 1}, true // SVG default fill is black
}
return rgba{}, false
}
if v == "none" || v == "transparent" {
return rgba{}, false
}
return parseColor(v), true
}
func attrFloat(attrs map[string]string, key string, def float64) float64 {
if v, ok := attrs[key]; ok {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func (r *Raster) blend(x, y int, c rgba, a float64) {
if a <= 0 {
return
}
if a > 1 {
a = 1
}
i := y*r.W + x
p := r.Pix[i]
p[0] = c.r*a + p[0]*(1-a)
p[1] = c.g*a + p[1]*(1-a)
p[2] = c.b*a + p[2]*(1-a)
p[3] = math.Max(p[3], a)
r.Pix[i] = p
}
func distSeg(x, y float64, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
l2 := dx*dx + dy*dy
if l2 == 0 {
return math.Hypot(x-a[0], y-a[1])
}
t := ((x-a[0])*dx + (y-a[1])*dy) / l2
t = math.Max(0, math.Min(1, t))
return math.Hypot(x-(a[0]+t*dx), y-(a[1]+t*dy))
}
func pointInPolygon(x, y float64, pts [][2]float64) bool {
in := false
n := len(pts)
for i, j := 0, n-1; i < n; j, i = i, i+1 {
xi, yi := pts[i][0], pts[i][1]
xj, yj := pts[j][0], pts[j][1]
if (yi > y) != (yj > y) && x < (xj-xi)*(y-yi)/(yj-yi)+xi {
in = !in
}
}
return in
}
// parseColor handles #rgb/#rrggbb plus the CSS names agents actually
// use; unknown names render mid-gray rather than failing the preview.
func parseColor(s string) rgba {
s = strings.TrimSpace(strings.ToLower(s))
if strings.HasPrefix(s, "#") {
h := s[1:]
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) == 6 {
r, err1 := strconv.ParseUint(h[0:2], 16, 8)
g, err2 := strconv.ParseUint(h[2:4], 16, 8)
b, err3 := strconv.ParseUint(h[4:6], 16, 8)
if err1 == nil && err2 == nil && err3 == nil {
return rgba{float64(r), float64(g), float64(b), 1}
}
}
}
if c, ok := cssColors[s]; ok {
return c
}
return rgba{128, 128, 128, 1}
}
var cssColors = map[string]rgba{
"black": {0, 0, 0, 1}, "white": {255, 255, 255, 1}, "red": {255, 0, 0, 1},
"green": {0, 128, 0, 1}, "lime": {0, 255, 0, 1}, "blue": {0, 0, 255, 1},
"yellow": {255, 255, 0, 1}, "orange": {255, 165, 0, 1}, "purple": {128, 0, 128, 1},
"gray": {128, 128, 128, 1}, "grey": {128, 128, 128, 1}, "silver": {192, 192, 192, 1},
"cyan": {0, 255, 255, 1}, "magenta": {255, 0, 255, 1}, "pink": {255, 192, 203, 1},
"brown": {165, 42, 42, 1}, "navy": {0, 0, 128, 1}, "teal": {0, 128, 128, 1},
"olive": {128, 128, 0, 1}, "maroon": {128, 0, 0, 1}, "aqua": {0, 255, 255, 1},
"fuchsia": {255, 0, 255, 1}, "gold": {255, 215, 0, 1},
}
// ANSI renders the raster with truecolor half-blocks, two pixel rows
// per text row — same technique as spritec's preview.
func (r *Raster) ANSI() string {
const reset = "\x1b[0m"
var b strings.Builder
for y := 0; y < r.H; y += 2 {
for x := 0; x < r.W; x++ {
top := r.Pix[y*r.W+x]
var bot [4]float64
if y+1 < r.H {
bot = r.Pix[(y+1)*r.W+x]
}
if top[3] == 0 && bot[3] == 0 {
b.WriteString(" ")
continue
}
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s",
int(top[0]), int(top[1]), int(top[2]),
int(bot[0]), int(bot[1]), int(bot[2]), reset)
}
b.WriteString("\n")
}
return b.String()
}

201
svg-maker/svg/svg_test.go Normal file
View File

@@ -0,0 +1,201 @@
package svg
import (
"strings"
"testing"
)
const sample = `# test scene
canvas 100 80
bg #112233
def accent #4f9cf9
rect 10 10 30 20 fill=$accent rx=3
circle 70 30 15 fill=#3fca7c stroke=white stroke-width=2
line 0 70 100 70 stroke=red width=3
polygon 10,60 30,40 50,60 fill=#e0a63f
path M60,60 L80,70 L90,50 Z stroke=white
text 50 25 "hi & <you>" size=10 fill=white anchor=middle
group stroke=gray
line 5 5 15 15
end
`
func TestParseAndEmit(t *testing.T) {
xml, doc, err := Build(sample)
if err != nil {
t.Fatal(err)
}
if doc.W != 100 || doc.H != 80 || doc.Bg != "#112233" {
t.Errorf("canvas/bg wrong: %+v", doc)
}
for _, want := range []string{
`viewBox="0 0 100 80"`,
`<rect x="10" y="10" width="30" height="20"`,
`fill="#4f9cf9"`, // $accent resolved
`rx="3"`,
`<circle cx="70" cy="30" r="15"`,
`stroke-width="3"`, // width alias
`<polygon points="10,60 30,40 50,60"`,
`<path d="M60,60 L80,70 L90,50 Z"`,
`hi &amp; &lt;you&gt;`, // escaped text
`text-anchor="middle"`,
`font-size="10"`,
`<g stroke="gray">`,
} {
if !strings.Contains(xml, want) {
t.Errorf("emitted SVG missing %q\n%s", want, xml)
}
}
}
func TestParseErrorsCarryLineNumbers(t *testing.T) {
cases := map[string]string{
"canvas 100 100\nrect 1 2 3": "line 2",
"canvas 100 100\nrect 1 2 3 four": "not a number",
"canvas 100 100\ncircle 1 2 3 glow=yes": "unknown attribute",
"canvas 100 100\nrect 1 2 3 4 fill=$missing": "undefined color variable",
"canvas 100 100\nblob 1 2": "unknown element",
"canvas 100 100\ngroup\nline 1 2 3 4": "never closed",
"canvas 100 100\nend": "end without group",
"canvas 100 100\npath X10,10": "path",
"canvas 100 100\nrect 1 2 3 4 fill=#zzz": "non-hex",
"rect 1 2 3 4": "missing canvas",
}
for src, want := range cases {
_, _, err := Build(src)
if err == nil {
t.Errorf("no error for %q", src)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("error for %q = %q, want substring %q", src, err, want)
}
}
}
func TestVisibilityDefaults(t *testing.T) {
xml, _, err := Build("canvas 10 10\nline 0 0 10 10\npolyline 0,0 5,5 10,0")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(xml, `<line x1="0" y1="0" x2="10" y2="10" stroke="black"/>`) {
t.Errorf("line did not get default stroke:\n%s", xml)
}
if !strings.Contains(xml, `fill="none"`) {
t.Errorf("polyline did not get fill=none:\n%s", xml)
}
}
func TestFlattenPath(t *testing.T) {
subs, err := flattenPath("M0,0 L10,0 V10 H0 Z")
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 {
t.Fatalf("want 1 subpath, got %d", len(subs))
}
pts := subs[0]
last := pts[len(pts)-1]
if last[0] != 0 || last[1] != 0 {
t.Errorf("Z should close back to start, ended at %v", last)
}
// curves flatten into many segments
subs, err = flattenPath("M0,0 Q50,100 100,0")
if err != nil {
t.Fatal(err)
}
if len(subs[0]) < 10 {
t.Errorf("quadratic should flatten to many points, got %d", len(subs[0]))
}
// relative commands
subs, err = flattenPath("m10,10 l10,0 l0,10 z")
if err != nil {
t.Fatal(err)
}
if got := subs[0][2]; got[0] != 20 || got[1] != 20 {
t.Errorf("relative path point = %v, want 20,20", got)
}
if _, err := flattenPath("L10,10"); err == nil {
t.Error("path not starting with M should fail")
}
if _, err := flattenPath("M0,0 A5,5 0 0 1 10,10"); err == nil {
t.Error("unsupported arc command should fail")
}
}
func TestRasterShapes(t *testing.T) {
doc, err := Parse("canvas 100 100\nbg black\ncircle 50 50 30 fill=red")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 50)
if r.W != 50 || r.H != 50 {
t.Fatalf("grid %dx%d, want 50x50", r.W, r.H)
}
center := r.Pix[25*r.W+25]
if center[0] < 200 || center[1] > 50 {
t.Errorf("center should be red, got %v", center)
}
corner := r.Pix[0]
if corner[0] > 50 && corner[1] > 50 && corner[2] > 50 {
t.Errorf("corner should be black, got %v", corner)
}
}
func TestRasterPolygonFill(t *testing.T) {
doc, err := Parse("canvas 100 100\npolygon 0,0 100,0 100,100 0,100 fill=#00ff00")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 20)
mid := r.Pix[10*r.W+10]
if mid[1] < 200 {
t.Errorf("polygon interior not filled: %v", mid)
}
}
func TestANSIPreviewShape(t *testing.T) {
doc, _ := Parse("canvas 40 20\nbg #000000\nrect 0 0 40 20 fill=white")
doc.normalize()
out := RenderGrid(doc, 40).ANSI()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 10 { // 20 pixel rows / 2 per text row
t.Errorf("ANSI preview has %d rows, want 10", len(lines))
}
if !strings.Contains(out, "▀") {
t.Error("preview contains no half-block characters")
}
}
func TestInfoWarnsOutsideCanvas(t *testing.T) {
doc, err := Parse("canvas 50 50\ncircle 25 25 10 fill=red\nrect 100 100 20 20 fill=blue\nline 40 40 60 60")
if err != nil {
t.Fatal(err)
}
doc.normalize()
info := Info(doc)
for _, want := range []string{
"canvas: 50x50",
"circle:1",
"entirely outside",
"sticks outside",
} {
if !strings.Contains(info, want) {
t.Errorf("info missing %q:\n%s", want, info)
}
}
}
func TestGroupAttrInheritanceInRaster(t *testing.T) {
doc, err := Parse("canvas 10 10\ngroup fill=#ff0000\nrect 0 0 10 10\nend")
if err != nil {
t.Fatal(err)
}
doc.normalize()
r := RenderGrid(doc, 10)
if p := r.Pix[5*r.W+5]; p[0] < 200 {
t.Errorf("group fill not inherited: %v", p)
}
}

44
waitfor/README.md Normal file
View File

@@ -0,0 +1,44 @@
# waitfor — block until a condition holds
Replaces the hand-rolled `while ! curl …; do sleep 5; done` loops that
each need a fresh approval in an agent session with **one stable
command prefix**. The agent makes one blocking call instead of burning
turns polling. Closes the `Monitor` gap from
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.2.
## Usage
```
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
[--timeout 20m] [--then "shell command"] [--verbose]
```
The condition holds when `--cmd` exits 0 **and**, if `--matches` is
given, its combined output matches the regex. The first attempt runs
immediately; then every `--interval` until `--timeout`.
| Exit | Meaning |
|------|---------|
| 0 | condition met (`--then` runs afterwards, if given) |
| 3 | timeout — last output still printed so the caller sees the state |
| 1 | usage/config error (empty `--cmd`, bad regex) |
Examples:
```bash
# wait until Gitea answers again
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
# wait until a container is listed (read-only Pi5 wrapper)
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
# wait for a file, then notify the phone (composes with notifyr)
waitfor --cmd "test -f /tmp/report.html" --then 'notifyr send --msg "report is ready"'
```
## Build & test
```bash
go test ./...
go build -o build/waitfor .
```

3
waitfor/go.mod Normal file
View File

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

86
waitfor/main.go Normal file
View File

@@ -0,0 +1,86 @@
// waitfor blocks until a shell condition holds — the agent-friendly
// replacement for hand-rolled poll loops that each need a fresh
// command approval. See doc/tool-parity.md §3.2.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
"gitea.brasse-pc.eu/brasse/agent-tools/waitfor/wait"
)
var version = "dev"
const usage = `waitfor - block until a condition holds
Usage:
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
[--timeout 20m] [--then "shell command"] [--verbose]
waitfor version
The condition holds when --cmd exits 0 and (if given) its combined
output matches --matches. The first attempt runs immediately.
Exit codes: 0 condition met, 3 timeout, 1 error. The last command
output is printed either way, so the caller always sees the state.
Examples:
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
waitfor --cmd "test -f /tmp/done" --then 'notifyr send --msg "done!"'
`
func main() {
if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Println("waitfor", version)
return
}
if len(os.Args) >= 2 && (os.Args[1] == "help" || os.Args[1] == "--help" || os.Args[1] == "-h") {
fmt.Print(usage)
return
}
fs := flag.NewFlagSet("waitfor", flag.ExitOnError)
cmd := fs.String("cmd", "", "shell command to poll (required)")
matches := fs.String("matches", "", "regex the output must match")
interval := fs.Duration("interval", 10*time.Second, "time between attempts")
timeout := fs.Duration("timeout", 10*time.Minute, "total time budget")
then := fs.String("then", "", "shell command to run when the condition is met")
verbose := fs.Bool("verbose", false, "log every attempt to stderr")
fs.Usage = func() { fmt.Fprint(os.Stderr, usage) }
fs.Parse(os.Args[1:])
if *cmd == "" {
fmt.Fprint(os.Stderr, usage)
os.Exit(1)
}
res, err := wait.Wait(wait.Options{
Cmd: *cmd, Matches: *matches, Interval: *interval, Timeout: *timeout, Verbose: *verbose,
}, wait.ShellRunner, func(s string) { fmt.Fprintln(os.Stderr, "waitfor: "+s) })
if err != nil {
fmt.Fprintln(os.Stderr, "waitfor:", err)
os.Exit(1)
}
if out := strings.TrimRight(res.LastOut, "\n"); out != "" {
fmt.Println(out)
}
if !res.Met {
fmt.Fprintf(os.Stderr, "waitfor: timeout after %s (%d attempts, last exit %d)\n",
res.Elapsed.Round(time.Second), res.Attempts, res.LastExit)
os.Exit(3)
}
fmt.Fprintf(os.Stderr, "waitfor: condition met after %s (%d attempts)\n",
res.Elapsed.Round(time.Millisecond), res.Attempts)
if *then != "" {
t := exec.Command("sh", "-c", *then)
t.Stdout, t.Stderr = os.Stdout, os.Stderr
if err := t.Run(); err != nil {
fmt.Fprintf(os.Stderr, "waitfor: --then command failed: %v\n", err)
}
}
}

94
waitfor/wait/wait.go Normal file
View File

@@ -0,0 +1,94 @@
// Package wait blocks until a shell condition holds: run a command
// every interval until it exits 0 (and, optionally, its output matches
// a regex) or a timeout expires. One blocking call instead of an
// agent burning turns on poll loops.
package wait
import (
"fmt"
"os/exec"
"regexp"
"time"
)
type Options struct {
Cmd string // shell command to run each attempt (required)
Matches string // optional regex the output must match
Interval time.Duration // between attempts
Timeout time.Duration // total budget
Verbose bool // progress line per attempt to the log func
}
type Result struct {
Met bool
Attempts int
Elapsed time.Duration
LastOut string
LastExit int
}
// Runner executes a shell command, returning combined output and exit
// code. Separated out so tests can fake it.
type Runner func(cmd string) (string, int)
// ShellRunner runs via sh -c with combined stdout+stderr.
func ShellRunner(cmd string) (string, int) {
c := exec.Command("sh", "-c", cmd)
out, err := c.CombinedOutput()
code := 0
if err != nil {
code = 1
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
return string(out), code
}
// Wait polls until the condition holds or the timeout expires. The
// first attempt runs immediately. log receives progress lines when
// Verbose is set (pass nil otherwise).
func Wait(opts Options, run Runner, log func(string)) (Result, error) {
if opts.Cmd == "" {
return Result{}, fmt.Errorf("no command given")
}
var re *regexp.Regexp
if opts.Matches != "" {
var err error
re, err = regexp.Compile(opts.Matches)
if err != nil {
return Result{}, fmt.Errorf("bad --matches regex: %v", err)
}
}
if opts.Interval <= 0 {
opts.Interval = 10 * time.Second
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Minute
}
start := time.Now()
res := Result{}
for {
res.Attempts++
out, code := run(opts.Cmd)
res.LastOut, res.LastExit = out, code
met := code == 0 && (re == nil || re.MatchString(out))
if opts.Verbose && log != nil {
state := "not yet"
if met {
state = "met"
}
log(fmt.Sprintf("attempt %d: exit %d, condition %s", res.Attempts, code, state))
}
if met {
res.Met = true
res.Elapsed = time.Since(start)
return res, nil
}
if time.Since(start)+opts.Interval > opts.Timeout {
res.Elapsed = time.Since(start)
return res, nil
}
time.Sleep(opts.Interval)
}
}

92
waitfor/wait/wait_test.go Normal file
View File

@@ -0,0 +1,92 @@
package wait
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestMetOnExitZero(t *testing.T) {
calls := 0
run := func(cmd string) (string, int) {
calls++
if calls < 3 {
return "not ready", 1
}
return "ready", 0
}
res, err := Wait(Options{Cmd: "x", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met || res.Attempts != 3 || res.LastOut != "ready" {
t.Errorf("unexpected result: %+v", res)
}
}
func TestMatchesRequiredOnTopOfExitZero(t *testing.T) {
calls := 0
run := func(cmd string) (string, int) {
calls++
if calls == 1 {
return "gitea starting", 0 // exit 0 but no match yet
}
return "gitea healthy", 0
}
res, err := Wait(Options{Cmd: "x", Matches: "healthy", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met || res.Attempts != 2 {
t.Errorf("match should gate success: %+v", res)
}
}
func TestTimeoutReportsLastOutput(t *testing.T) {
run := func(cmd string) (string, int) { return "still broken", 7 }
res, err := Wait(Options{Cmd: "x", Interval: 5 * time.Millisecond, Timeout: 20 * time.Millisecond}, run, nil)
if err != nil {
t.Fatal(err)
}
if res.Met {
t.Error("should have timed out")
}
if res.LastOut != "still broken" || res.LastExit != 7 {
t.Errorf("last output lost: %+v", res)
}
if res.Attempts < 1 {
t.Error("should have tried at least once")
}
}
func TestBadRegexRejected(t *testing.T) {
if _, err := Wait(Options{Cmd: "x", Matches: "("}, nil, nil); err == nil {
t.Error("bad regex accepted")
}
}
func TestEmptyCommandRejected(t *testing.T) {
if _, err := Wait(Options{}, nil, nil); err == nil {
t.Error("empty command accepted")
}
}
// Integration: real shell, waiting for a file to appear (the exact
// case from the agy live test in doc/tool-parity.md).
func TestShellRunnerFileAppears(t *testing.T) {
path := filepath.Join(t.TempDir(), "flag")
go func() {
time.Sleep(30 * time.Millisecond)
os.WriteFile(path, []byte("x"), 0o644)
}()
res, err := Wait(Options{
Cmd: "test -f " + path, Interval: 10 * time.Millisecond, Timeout: 2 * time.Second,
}, ShellRunner, nil)
if err != nil {
t.Fatal(err)
}
if !res.Met {
t.Errorf("file never seen: %+v", res)
}
}