9 Commits

Author SHA1 Message Date
clawbot
7f7c9c67a0 keyfunc: age keys, age encrypt and decrypt, and derived mnemonics are in scope
Per sneak. The age commands derive the identity at the generic path and
encrypt or decrypt with it, always including the identity's own
recipient. The mnemonic command derives child mnemonics through BIP-85's
own mnemonic application.

Model: fable-5-1
2026-09-07 14:16:29 +00:00
clawbot
b2214ca5c5 keyfunc: the tool's name, chosen by sneak
The name says what the tool is: a key is a function of the mnemonic and
an index, computed when asked for and stored nowhere. Directory, module,
binary and environment variables take it.

Model: fable-5-1
2026-09-07 13:29:02 +00:00
clawbot
933fa164c1 bip85keys: generic derivation path, ssh to, new test vectors
Per sneak: the path carries no vendor id, since this is meant as a
standard others can follow. Application numbers follow BIP-85's own
spelling for RSA: SSH is 838372, age is 657169. The ssh subcommand that
runs the system ssh is now "to". Test vectors recomputed for the new
path. secret's age keys move to this path in a change there.

Model: fable-5-1
2026-09-06 22:06:27 +00:00
clawbot
8e24b0ae88 bip85keys: mnemonic only, and a command that produces it
Per sneak: no xprv input. The mnemonic comes from a shell command given
as a flag or an environment variable (for example `secret get foo`),
from an environment variable holding it, or from a no-echo prompt. The
file flag is dropped since the command covers it.

Model: fable-5-1
2026-09-06 14:35:30 +00:00
clawbot
8439d6bfad bip85keys: rename from bip85ssh and group commands by key type
The tool will cover more key types than SSH, so the directory, module
and binary become bip85keys and the commands are grouped by type:
"bip85keys ssh pub|priv|install|ssh" now, "bip85keys age pub|priv"
planned. The README states the age derivation (agehd's path in
sneak/secret) and that adding a type is one package plus one
subcommand. The SSH application id and everything else stay as before.

Model: fable-5-1
2026-09-06 14:29:22 +00:00
clawbot
d3daa14d54 bip85ssh: add spec for deterministic SSH keys from BIP-85
README for a new tool that derives ed25519 SSH key pairs from a BIP-39
mnemonic or xprv, using pkg/bip85 from sneak/secret with the same steps
as agehd. Path m/83696968'/592366788'/1822331379'/n'. Four commands:
pub, priv, install, ssh. The README is the spec; the code follows in
later commits.

Model: fable-5-1
2026-09-06 14:19:32 +00:00
fcb956124b update rclone backup script for ber1ds1 2026-08-20 09:40:21 +02:00
cb18297d11 add find-and-enflac pcm compressor 2026-08-08 21:31:52 +02:00
2fe5db60c2 latest 2026-07-06 19:12:44 +02:00
13 changed files with 1183 additions and 0 deletions

1
find-and-enflac/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/find-and-enflac

33
find-and-enflac/Makefile Normal file
View File

@@ -0,0 +1,33 @@
BIN := find-and-enflac
PREFIX := $(HOME)
.PHONY: default install build check test lint fmt fmt-check clean
default: install
install: build
install -d $(PREFIX)/bin
install -m 0755 $(BIN) $(PREFIX)/bin/$(BIN)
build:
go build -o $(BIN) .
check: fmt-check lint test
test:
go test ./...
lint:
go vet ./...
fmt:
gofmt -s -w .
fmt-check:
@out="$$(gofmt -s -l .)"; \
if [ -n "$$out" ]; then \
echo "unformatted files:"; echo "$$out"; exit 1; \
fi
clean:
rm -f $(BIN)

211
find-and-enflac/encode.go Normal file
View File

@@ -0,0 +1,211 @@
package main
import (
"bytes"
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Container extensions holding uncompressed PCM. Raw headerless .pcm is
// excluded on purpose: sample rate, width and channel count cannot be
// recovered from the file itself.
var pcmExts = map[string]bool{
".wav": true,
".wave": true,
".bwf": true,
".rf64": true,
".bw64": true,
".w64": true,
".aif": true,
".aiff": true,
".aifc": true,
}
// findPCM returns every regular file under root whose extension names a
// PCM container. Symlinks are not followed.
func findPCM(root string) ([]string, error) {
var found []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.Type().IsRegular() {
return nil
}
if pcmExts[strings.ToLower(filepath.Ext(path))] {
found = append(found, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(found)
return found, nil
}
type encoder struct {
threads int
dryRun bool
}
// encodeOne compresses src, verifies the result decodes back to
// identical audio, and only then removes src.
func (e *encoder) encodeOne(ctx context.Context, src string) error {
codecName, err := probeCodec(ctx, src)
if err != nil {
return err
}
var dst, codec, level string
switch {
case strings.HasPrefix(codecName, "pcm_f32"), strings.HasPrefix(codecName, "pcm_f64"):
dst, codec, level = replaceExt(src, ".wv"), "wavpack", "3"
case strings.HasPrefix(codecName, "pcm_"):
dst, codec, level = replaceExt(src, ".flac"), "flac", "12"
default:
slog.Warn("skipping, not PCM", "src", src, "codec", codecName)
return nil
}
if _, err := os.Lstat(dst); err == nil {
return fmt.Errorf("destination already exists: %s", dst)
}
if e.dryRun {
slog.Info("would encode", "src", src, "dst", dst, "codec", codec)
return nil
}
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if err := ffmpeg(ctx,
"-threads", fmt.Sprint(e.threads),
"-i", src,
"-map", "0:a:0",
"-c:a", codec,
"-compression_level", level,
dst,
); err != nil {
os.Remove(dst)
return err
}
srcSum, err := audioMD5(ctx, src)
if err != nil {
os.Remove(dst)
return err
}
dstSum, err := audioMD5(ctx, dst)
if err != nil {
os.Remove(dst)
return err
}
if srcSum != dstSum {
os.Remove(dst)
return fmt.Errorf("verification failed, original kept: %s != %s", srcSum, dstSum)
}
dstInfo, err := os.Stat(dst)
if err != nil {
return err
}
if err := os.Remove(src); err != nil {
return err
}
slog.Info("encoded",
"src", src,
"dst", dst,
"ratio", fmt.Sprintf("%.3f", float64(dstInfo.Size())/float64(srcInfo.Size())),
)
return nil
}
// probeCodec reports the codec of the file's first audio stream, e.g.
// pcm_s24le or pcm_f32le.
func probeCodec(ctx context.Context, path string) (string, error) {
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=nw=1:nokey=1",
path,
)
cmd.Stdin = nil
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ffprobe: %w: %s", err, strings.TrimSpace(stderr.String()))
}
codec := strings.TrimSpace(stdout.String())
if codec == "" {
return "", fmt.Errorf("no audio stream")
}
return codec, nil
}
// audioMD5 hashes the decoded audio, forced to 64-bit float so that
// neither 32-bit integer nor 32-bit float samples lose anything on the
// way to the hash. Without an explicit codec the md5 muxer would
// default to pcm_s16le and hide real differences.
func audioMD5(ctx context.Context, path string) (string, error) {
cmd := exec.CommandContext(ctx, "ffmpeg",
"-nostdin", "-v", "error",
"-i", path,
"-map", "0:a:0",
"-c:a", "pcm_f64le",
"-f", "md5", "-",
)
cmd.Stdin = nil
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ffmpeg md5 of %s: %w: %s", path, err, strings.TrimSpace(stderr.String()))
}
sum := strings.TrimSpace(stdout.String())
if !strings.HasPrefix(sum, "MD5=") {
return "", fmt.Errorf("unexpected md5 output for %s: %q", path, sum)
}
return strings.TrimPrefix(sum, "MD5="), nil
}
func ffmpeg(ctx context.Context, args ...string) error {
full := append([]string{"-nostdin", "-v", "error"}, args...)
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
cmd.Stdin = nil
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func replaceExt(path, ext string) string {
return strings.TrimSuffix(path, filepath.Ext(path)) + ext
}

3
find-and-enflac/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.eeqj.de/sneak/hacks/find-and-enflac
go 1.26

121
find-and-enflac/main.go Normal file
View File

@@ -0,0 +1,121 @@
// Command find-and-enflac walks a directory tree, finds every
// uncompressed PCM audio file, and losslessly compresses them all in
// parallel. An original is deleted only after its encoded replacement
// has been verified to decode back to bit-identical audio.
//
// Integer PCM becomes FLAC. IEEE float PCM becomes WavPack, which is
// the only common lossless codec that stores float samples exactly;
// FLAC has no float sample type at all.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"runtime"
"sync"
"sync/atomic"
"syscall"
)
func main() {
jobs := flag.Int("jobs", runtime.NumCPU(), "files to encode concurrently")
threads := flag.Int("threads", runtime.NumCPU(), "threads per ffmpeg")
dryRun := flag.Bool("n", false, "report what would be done, change nothing")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: %s [flags] [directory]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Losslessly compress PCM audio under directory (default \".\").\n\n")
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() > 1 {
flag.Usage()
os.Exit(2)
}
root := "."
if flag.NArg() == 1 {
root = flag.Arg(0)
}
if err := run(root, *jobs, *threads, *dryRun); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run(root string, jobs, threads int, dryRun bool) error {
for _, tool := range []string{"ffmpeg", "ffprobe"} {
if _, err := exec.LookPath(tool); err != nil {
return fmt.Errorf("%s not found in PATH: %w", tool, err)
}
}
if jobs < 1 || threads < 1 {
return fmt.Errorf("jobs and threads must be at least 1")
}
info, err := os.Stat(root)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", root)
}
// Ctrl-C cancels in-flight ffmpeg runs; a killed encode fails
// verification, so its partial output is removed and its source
// is left alone.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srcs, err := findPCM(root)
if err != nil {
return err
}
if len(srcs) == 0 {
slog.Info("no PCM audio found", "root", root)
return nil
}
slog.Info("found PCM audio", "files", len(srcs), "jobs", jobs, "threads", threads)
enc := &encoder{threads: threads, dryRun: dryRun}
var failed atomic.Int64
queue := make(chan string)
var wg sync.WaitGroup
for range jobs {
wg.Go(func() {
for src := range queue {
if err := enc.encodeOne(ctx, src); err != nil {
slog.Error("failed", "src", src, "err", err)
failed.Add(1)
}
}
})
}
for _, src := range srcs {
if ctx.Err() != nil {
break
}
queue <- src
}
close(queue)
wg.Wait()
if n := failed.Load(); n > 0 {
return fmt.Errorf("%d of %d file(s) failed", n, len(srcs))
}
if ctx.Err() != nil {
return ctx.Err()
}
return nil
}

28
flappy/go.mod Normal file
View File

@@ -0,0 +1,28 @@
module flappy
go 1.25
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

43
flappy/go.sum Normal file
View File

@@ -0,0 +1,43 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

382
flappy/main.go Normal file
View File

@@ -0,0 +1,382 @@
package main
import (
"fmt"
"math/rand"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Physics tuned so per-frame displacement stays around one row, which keeps
// motion smooth and the 7-row gap threadable at 30fps.
const (
fps = 30
gravity = 0.09 // rows/frame^2
jumpVel = -0.95 // rows/frame; peak rise ≈ v²/2g ≈ 5 rows over ~0.35s
maxFall = 1.15 // terminal velocity, rows/frame — never skip >1 row
pipeSpeed = 0.5 // cells/frame → 15 cells/s
pipeGap = 7
pipeSpacing = 28 // cells between pipes → one pipe ≈ every 1.9s
pipeWidth = 3
groundH = 2
)
// tickMsg advances the simulation. gen guards against stale tick chains:
// without it, restarting can leave an old chain in flight and the game
// silently runs at double speed.
type tickMsg struct{ gen int }
func tick(gen int) tea.Cmd {
return tea.Tick(time.Second/fps, func(time.Time) tea.Msg {
return tickMsg{gen: gen}
})
}
type pipe struct {
x float64
gapY int
scored bool
}
type star struct{ x, y int }
type model struct {
termW, termH int
width int // field width in cells
height int // field height in cells (terminal minus header+footer)
birdX int
birdY float64
birdVel float64
pipes []pipe
stars []star
score int
best int
gen int // tick generation
gameOver bool
started bool
resized bool
}
func newGame(best, gen int, termW, termH int) model {
m := model{best: best, gen: gen}
if termW > 0 && termH > 0 {
m.applySize(termW, termH)
}
return m
}
// applySize records the terminal size, sizes the field, and rebuilds the
// static starfield. The starfield must be stable between frames — rolling
// new random speckles every render is what caused full-screen flicker.
func (m *model) applySize(w, h int) {
m.termW, m.termH = w, h
m.width = w
fieldH := h - 2 // one row header, one row footer
if fieldH < 8 {
fieldH = 8
}
m.height = fieldH
m.birdX = w / 4
if m.birdX < 4 {
m.birdX = 4
}
if !m.resized {
m.birdY = float64(m.height-groundH) / 2
m.resized = true
}
// keep the bird inside the new bounds after a resize
floor := float64(m.height - groundH - 1)
if m.birdY > floor {
m.birdY = floor
}
// static starfield, ~1 speckle per 40 sky cells
skyH := m.height - groundH
m.stars = m.stars[:0]
n := m.width * skyH / 40
for i := 0; i < n; i++ {
m.stars = append(m.stars, star{x: rand.Intn(m.width), y: rand.Intn(skyH)})
}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.applySize(msg.Width, msg.Height)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case " ", "space", "enter", "up", "k":
if m.gameOver {
// restart: bump generation so any in-flight tick is ignored
nm := newGame(m.best, m.gen+1, m.termW, m.termH)
nm.started = true
nm.birdVel = jumpVel // start with a flap, like the original
return nm, tick(nm.gen)
}
if !m.started {
m.started = true
m.birdVel = jumpVel
return m, tick(m.gen)
}
m.birdVel = jumpVel
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
case tickMsg:
if msg.gen != m.gen {
return m, nil // stale chain — drop it
}
if !m.started || m.gameOver {
return m, nil // stop the loop; nothing animates on end screens
}
if m.resized {
m.step()
}
return m, tick(m.gen)
}
return m, nil
}
func (m *model) step() {
m.birdVel += gravity
if m.birdVel > maxFall {
m.birdVel = maxFall
}
m.birdY += m.birdVel
// ceiling clamp
if m.birdY < 0 {
m.birdY = 0
m.birdVel = 0
}
// ground collision
floor := float64(m.height - groundH - 1)
if m.birdY >= floor {
m.birdY = floor
m.gameOver = true
}
// advance pipes
for i := range m.pipes {
m.pipes[i].x -= pipeSpeed
}
for len(m.pipes) > 0 && m.pipes[0].x+pipeWidth < 0 {
m.pipes = m.pipes[1:]
}
// spawn pipes
usableH := m.height - groundH - pipeGap - 4
if usableH < 1 {
usableH = 1
}
if len(m.pipes) == 0 || m.pipes[len(m.pipes)-1].x < float64(m.width-pipeSpacing) {
gapY := rand.Intn(usableH) + 2
m.pipes = append(m.pipes, pipe{x: float64(m.width), gapY: gapY})
}
by := int(m.birdY)
for i := range m.pipes {
p := &m.pipes[i]
px := int(p.x)
// score once the pipe's trailing edge passes the bird
if !p.scored && px+pipeWidth <= m.birdX {
p.scored = true
m.score++
if m.score > m.best {
m.best = m.score
}
}
// collision with pipe body
if m.birdX >= px && m.birdX < px+pipeWidth {
if by < p.gapY || by > p.gapY+pipeGap {
m.gameOver = true
}
}
}
}
// styles ----------------------------------------------------------------------
var (
birdStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
birdUpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
birdDnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
pipeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
pipeHi = lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
capStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36"))
skyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
groundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("142"))
dirtStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130"))
scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
bestStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
overStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("220")).
Background(lipgloss.Color("23")).
Bold(true)
)
func birdGlyph(vel float64) (string, lipgloss.Style) {
switch {
case vel < -0.3:
return "➚", birdUpStyle
case vel > 0.5:
return "➘", birdDnStyle
default:
return "➙", birdStyle
}
}
func (m model) View() string {
if !m.resized {
return hintStyle.Render(" sizing terminal… ")
}
width, height := m.width, m.height
grid := make([][]string, height)
for i := range grid {
grid[i] = make([]string, width)
for j := range grid[i] {
grid[i][j] = " "
}
}
// static sky speckles (precomputed — stable between frames)
for _, s := range m.stars {
if s.y < height-groundH && s.x < width {
grid[s.y][s.x] = skyStyle.Render("·")
}
}
// pipes
for _, p := range m.pipes {
px := int(p.x)
topCap := p.gapY - 1
botCap := p.gapY + pipeGap + 1
for x := px; x < px+pipeWidth && x < width; x++ {
if x < 0 {
continue
}
for y := 0; y < height-groundH; y++ {
if y < p.gapY || y > p.gapY+pipeGap {
if y == topCap || y == botCap {
grid[y][x] = capStyle.Render("▓")
} else if x == px {
grid[y][x] = pipeHi.Render("█")
} else {
grid[y][x] = pipeStyle.Render("█")
}
}
}
}
}
// ground
for y := height - groundH; y < height; y++ {
for x := 0; x < width; x++ {
if y == height-groundH {
grid[y][x] = groundStyle.Render("▔")
} else {
grid[y][x] = dirtStyle.Render("▓")
}
}
}
// bird
by := int(m.birdY)
if by >= 0 && by < height && m.birdX >= 0 && m.birdX < width {
glyph, style := birdGlyph(m.birdVel)
grid[by][m.birdX] = style.Render(glyph)
}
// overlays are written directly into the grid, cell by cell, so styled
// field content underneath can never be corrupted by string splicing.
if !m.started && !m.gameOver {
m.overlayGrid(grid, []overline{
{" FLAPPY BIRD ", titleStyle},
{},
{"press SPACE to flap", hintStyle},
})
} else if m.gameOver {
m.overlayGrid(grid, []overline{
{"✖ GAME OVER ✖", overStyle},
{},
{fmt.Sprintf("score %d • SPACE to retry", m.score), hintStyle},
})
}
// compose field
var b strings.Builder
for _, row := range grid {
for _, c := range row {
b.WriteString(c)
}
b.WriteString("\n")
}
field := b.String()
header := scoreStyle.Render(fmt.Sprintf(" SCORE %d ", m.score)) +
" " +
bestStyle.Render(fmt.Sprintf("BEST %d ", m.best))
footer := hintStyle.Render(" SPACE: flap • Q: quit ")
return header + "\n" + field + footer
}
// overline is one centred line of overlay text with its style.
type overline struct {
text string
style lipgloss.Style
}
// overlayGrid writes overlay lines into the middle of the grid, one styled
// cell per rune. Empty lines clear a centred band for readability.
func (m model) overlayGrid(grid [][]string, lines []overline) {
startY := (len(grid) - len(lines)) / 2
if startY < 0 {
startY = 0
}
// widest line determines the cleared band
maxW := 0
for _, ol := range lines {
if n := len([]rune(ol.text)); n > maxW {
maxW = n
}
}
for i, ol := range lines {
y := startY + i
if y < 0 || y >= len(grid) {
continue
}
// clear a band as wide as the widest line so text sits on a clean row
bandX := (m.width - maxW - 2) / 2
for j := 0; j < maxW+2; j++ {
x := bandX + j
if x >= 0 && x < m.width {
grid[y][x] = " "
}
}
runes := []rune(ol.text)
startX := (m.width - len(runes)) / 2
for j, r := range runes {
x := startX + j
if x < 0 || x >= m.width {
continue
}
grid[y][x] = ol.style.Render(string(r))
}
}
}
func main() {
p := tea.NewProgram(newGame(0, 0, 0, 0), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Println("error running game:", err)
}
}

168
geolocate/geolocate.go Normal file
View File

@@ -0,0 +1,168 @@
// Command geolocate estimates the machine's physical location by scanning
// nearby Wi-Fi access points and submitting their BSSIDs to a public
// Wi-Fi-to-location service, then records the result under ~/.data/location.
//
// This is a macOS port of the original Linux script. Two things changed since
// the original was written:
//
// - The original scanned access points with nmcli (NetworkManager). macOS
// has no equivalent, and Apple removed the `airport` CLI in macOS 14.4.
// On macOS 26, CoreWLAN only returns real BSSIDs to a Developer-ID-signed,
// notarized binary that the user has granted Location Services access. We
// get that via the macwifi package, which ships such a signed helper.
//
// - The original queried Mozilla Location Services, which shut down in 2024.
// We use BeaconDB instead: a community-run successor that speaks the same
// Ichnaea API (identical request/response shape).
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/jaisonerick/macwifi"
)
// geolocateURL is BeaconDB's Ichnaea-compatible geolocate endpoint, the
// drop-in replacement for the defunct location.services.mozilla.com.
const geolocateURL = "https://api.beacondb.net/v1/geolocate"
// accessPoint is one observed Wi-Fi access point in the Ichnaea request
// schema. Only macAddress is required; signal strength and channel improve
// the position estimate when available.
type accessPoint struct {
MacAddress string `json:"macAddress"`
SignalStrength int `json:"signalStrength,omitempty"`
Channel int `json:"channel,omitempty"`
}
type geolocateRequest struct {
WifiAccessPoints []accessPoint `json:"wifiAccessPoints,omitempty"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "geolocate:", err)
os.Exit(1)
}
}
func run() error {
ts := time.Now().Unix()
// The first scan triggers macOS's Location Services prompt for the
// bundled helper; allow generous time for a user to approve it.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Wi-Fi scanning requires Location Services. If it is disabled (or the
// scan otherwise fails), fall back to IP-based geolocation rather than
// aborting — matching the original, which posted an empty request when
// it couldn't find enough access points.
aps, err := listAccessPoints(ctx)
if err != nil {
fmt.Fprintf(os.Stderr,
"geolocate: wifi scan unavailable (%v); falling back to IP geolocation\n", err)
aps = nil
}
loc, err := geolocate(aps)
if err != nil {
return err
}
loc["timestamp"] = ts
out, err := json.Marshal(loc)
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
dir := filepath.Join(home, ".data", "location")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := writeFile(filepath.Join(dir, "latest.json"), out); err != nil {
return err
}
return writeFile(filepath.Join(dir, fmt.Sprintf("%d.json", ts)), out)
}
func writeFile(path string, content []byte) error {
return os.WriteFile(path, content, 0o644)
}
// listAccessPoints scans nearby Wi-Fi networks via CoreWLAN (through the
// macwifi helper) and returns their BSSIDs. It returns nil if fewer than two
// usable access points are found, mirroring the original script: the
// geolocation service needs at least two observations to triangulate.
func listAccessPoints(ctx context.Context) ([]accessPoint, error) {
networks, err := macwifi.Scan(ctx)
if err != nil {
return nil, fmt.Errorf("scanning wifi: %w", err)
}
seen := make(map[string]bool)
var aps []accessPoint
for _, n := range networks {
// BSSID is empty when Location Services access has not been
// granted, or for saved-but-not-visible networks.
if n.BSSID == "" || seen[n.BSSID] {
continue
}
seen[n.BSSID] = true
aps = append(aps, accessPoint{
MacAddress: n.BSSID,
SignalStrength: n.RSSI,
Channel: n.Channel,
})
}
if len(aps) < 2 {
return nil, nil
}
return aps, nil
}
// geolocate posts the observed access points to BeaconDB and returns the
// decoded response (e.g. {"location":{"lat":..,"lng":..},"accuracy":..}).
// With no access points it degrades to a GeoIP estimate, matching the
// original behaviour of posting an empty request.
func geolocate(aps []accessPoint) (map[string]any, error) {
body, err := json.Marshal(geolocateRequest{WifiAccessPoints: aps})
if err != nil {
return nil, err
}
resp, err := http.Post(geolocateURL, "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("geolocation service returned %s: %s",
resp.Status, bytes.TrimSpace(data))
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}

5
geolocate/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module sneak.berlin/go/geolocate
go 1.26.1
require github.com/jaisonerick/macwifi v1.0.0

2
geolocate/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/jaisonerick/macwifi v1.0.0 h1:Jy86C7BBR/J7u1cxFWOWi+YlwYjiMMjYLUt6RGFD1Mc=
github.com/jaisonerick/macwifi v1.0.0/go.mod h1:AtCf/s2Y4izU7IYcBSPoGe2lp2Oj53T7rz9D08uqSRM=

180
keyfunc/README.md Normal file
View File

@@ -0,0 +1,180 @@
# keyfunc
`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from
that mnemonic at any time. The same mnemonic, key type and index always give the
same key.
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
takes the same steps as that repository's `agehd` package.
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH
keys, `keyfunc age ...` for age identities and for encrypting and decrypting
with them, and `keyfunc mnemonic ...` for child mnemonics derived from the
main one.
## Derivation
The mnemonic is turned into a key like this:
1. mnemonic -> BIP-39 seed (empty passphrase);
2. seed -> BIP-32 master key;
3. master key -> 64 bytes of BIP-85 entropy at the path below;
4. entropy -> BIP-85 DRNG (SHAKE256); read 32 bytes;
5. those 32 bytes become the key in the way the key type needs.
The path is:
```
m/83696968'/<app>'/<n>'
```
- `83696968` is the fixed BIP-85 purpose.
- `app` is the application number of the key type. There is no vendor id: the
path is meant as a standard any implementation can follow, not something tied
to one tool. Each key type's number is spelled the way BIP-85 spells its own
RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out):
SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`).
- `n` is the key index: flag `--index` / `-n`, default `0`.
## Giving it the mnemonic
The mnemonic itself is never a command-line argument. It is looked for in this
order; the first one found wins:
1. `--mnemonic-command <command>`: a shell command, run with `sh -c`, whose
standard output is the mnemonic. Example: `--mnemonic-command 'secret get
foo'`. Whitespace around the output is dropped. If the command exits with a
non-zero status, the tool prints its standard error and exits with status 1.
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell
command held in the environment.
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
4. A prompt on the terminal with echo turned off.
If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
refused with a message saying so.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version set at build time.
## SSH keys: `keyfunc ssh`
Only ed25519 keys are produced. The application number is `838372`, so the
path is `m/83696968'/838372'/<n>'`. The 32 bytes from step 4 are the ed25519
seed.
Test vector, mnemonic
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
```
index 0: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZOtOczrc/7CQytcuFwt7s4r8KjkZWkwjLZWBaFKD+7
index 1: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEWY8+/gmHYVC4u0Y0I4FKs+eVUulTPHfk9VtXw1tMF
```
### `keyfunc ssh pub`
Prints one `authorized_keys` line to standard output:
```
ssh-ed25519 <key> <comment>
```
The comment defaults to `keyfunc/ssh/<n>`; change it with `--comment`.
### `keyfunc ssh priv`
Prints the unencrypted private key in OpenSSH format (the
`-----BEGIN OPENSSH PRIVATE KEY-----` block that `ssh` reads) to standard output
and nothing else, so it can be redirected into a file. The key's comment is the
same as for `pub`.
### `keyfunc ssh install <[user@]host> [-- ssh options...]`
Runs the system `ssh` to the host and, on the host:
- creates `~/.ssh` with mode `0700` if it is missing;
- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing;
- appends the `pub` line only if an identical line is not already there.
It then prints `added` or `already present`. How this `ssh` connection
authenticates is up to the user's normal `ssh` setup (existing keys, agent,
password). Anything after `--` is passed to `ssh` unchanged.
### `keyfunc ssh to <host> [ssh arguments...]`
Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk.
## age identities: `keyfunc age`
The application number is `657169`, path `m/83696968'/657169'/<n>'`. The 32
bytes from step 4 are clamped as X25519 requires and become an age identity,
the same steps `sneak/secret` takes in its `agehd` package. `secret` derives at
a vendor-specific path today; for its keys to equal this tool's it moves to
this path, which is a change in `secret`, not here.
### `keyfunc age pub`
Prints the recipient, the `age1...` public key, on one line.
### `keyfunc age priv`
Prints the identity, the `AGE-SECRET-KEY-1...` line, and nothing else.
### `keyfunc age encrypt [-n N] [--to <recipient>...] [-o <file>] [<file>]`
Encrypts the file (or standard input) with age. The recipients are the derived
identity's own recipient, plus any given with `--to`, so the same mnemonic can
always decrypt what it encrypted. Output goes to `-o` or standard output;
`--armor` writes the text form. Nothing is written except the output.
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
Decrypts the file (or standard input) with the derived identity. Output goes to
`-o` or standard output. If the identity is not one of the recipients, the tool
says so and exits with status 1.
## Derived mnemonics: `keyfunc mnemonic`
### `keyfunc mnemonic [-n N] [--words 12|18|24]`
Prints a child mnemonic derived from the main one, using BIP-85's own mnemonic
application (number `39`, English, path
`m/83696968'/39'/0'/<words>'/<n>'`, entropy taken as the specification says,
not through step 4). Default 12 words. A child mnemonic is a full mnemonic in
its own right: it can seed another `keyfunc`, another wallet, or `secret`, and
it never has to be written down, since it can be derived again.
## Adding a key type
Adding a key type is one package under `internal/` that turns the 32 derived
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
groups its commands.
## Errors
Errors go to standard error and the exit status is 1, except for `ssh to`,
which passes through `ssh`'s own exit status.
## Building and running
```
make build # produces ./keyfunc
make check # fmt-check, lint (golangci-lint) and tests
```
Examples:
```
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
keyfunc ssh install -n 3 user@example.com
keyfunc ssh to -n 3 user@example.com uptime
keyfunc age pub -n 0
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
keyfunc age decrypt -n 0 notes.age
keyfunc mnemonic -n 1 --words 24
```

View File

@@ -26,6 +26,9 @@ OPTS+=" --links --metadata"
# from the destination after the transfer completes.
OPTS+=" --delete-excluded --delete-after"
OPTS+=" --progress --transfers 25 --stats-unit bits --retries 10"
OPTS+=" --check-first"
OPTS+=" --max-backlog 10000000000"
OPTS+=" --order-by modtime,mixed,10"
RE=""
@@ -91,6 +94,9 @@ RE+=" --exclude=/.npm/"
RE+=" --exclude=/.opencode/node_modules/"
RE+=" --exclude=/.walletwasabi/"
# new 2026-08
RE+=" --exclude=/Library/Metadata/CoreSpotlight/"
MINRE=""
MINRE+=" --exclude=/.fseventsd/"
MINRE+=" --exclude=/.Spotlight-V100/"