diff --git a/.gitignore b/.gitignore index 7a12a2f..ab0de04 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ workspace/.claude/settings.local.json bin/fritz-poc bin/presence bin/_fritz.py +bin/_arp.py config/people.yaml* config/people-enrolled.tsv* config/presence.db* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9da54..cc5b3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ All notable changes to this project are documented here. The format is based on they stay in lockstep, and the tracked `CLAUDE.md` now defers to the host notes for name/language. i18n is intentionally instruction-only (no per-locale string files or pre-translated context variants) — the model handles cross-language output from the one-line directive. +- **Backups of host-local files.** New `bin/backup` archives everything host-specific — i.e. the + gitignored set (`.env`, `state/`, `tickets/`, `memory-backups/`, the presence DB, and any + host-local `bin`/`config` extensions) — into a timestamped, `chmod 600` `.tar.gz`. The manifest is + derived from `git ls-files --ignored`, so it can never drift from `.gitignore`. Archives land + **outside** the repo (`~/ogma-backups/` by default; `--out`/`OGMA_BACKUP_DIR`) so they survive + `git pull`, reinstalls, and uninstall, with retention to the newest 14 (`--keep`/`OGMA_BACKUP_KEEP`). + Runnable on demand, via the bot (new whitelisted `ogmactl backup`), or nightly through the new + `ogma-backup.timer` (installed by `bin/setup`, notifies on completion). `--list` shows existing + archives. +- **Uninstall path (`bin/uninstall`).** Backs up host-local files first (unless `--no-backup`), stops + and removes the systemd `--user` units and the copied skills, then **deletes the Ogma directory + itself** — the script `exec`s `rm`, so the repo can erase the very script that's running (no npx + needed). System-level units (e.g. `ogma-pihole-watch`) are left to a printed `sudo` command rather + than touched. Your Claude memory directory and the backup archives are never deleted. Guards refuse + to run against `$HOME`, `/`, or anything that isn't an Ogma checkout. Flags: `-y/--yes`, + `--no-backup`, `--backup-dir`, `--keep-skills`. ## [1.1.2] — 2026-06-18 diff --git a/README.md b/README.md index 7f37703..579a1c8 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ See [`skills/README.md`](skills/README.md) for details and how to write your own ## Self-management (`ogmactl`) `bin/ogmactl` is the **only** shell command the bot is permitted to run (a fixed whitelist of subcommands — granting it does *not* grant arbitrary shell): `status`, `logs [N]`, `restart`, -`health`, `ticket `, `tickets`. `bin/setup` pre-approves it (and read access to your memory +`health`, `backup`, `ticket `, `tickets`. `bin/setup` pre-approves it (and read access to your memory directory) in `workspace/.claude/settings.json`, so the bot can self-manage and recall memory over Telegram without hitting permission prompts — the headless gateway can't show an approval UI. This grant is read-only by design: no `Write`/`Edit`/arbitrary-`Bash`. (`OGMA_ALLOWED_TOOLS` in `.env` @@ -174,6 +174,34 @@ install there's no such file and unknown commands are refused as before. - **`bin/health-check`** — every ~5 min, alerts to Telegram if CPU temp / load / disk / free RAM cross thresholds (all `HEALTH_*`-overridable). Pure shell; the temp check skips cleanly on hosts that don't expose it. +- **`bin/backup`** — nightly archive of your host-local files (see **Backups** below). + +## Backups +`bin/backup` archives **all your host-local files** — everything that's gitignored: `.env` +(your token + persona), `state/`, `tickets/`, `memory-backups/`, the presence DB, and any +host-local `bin`/`config` extensions. The manifest *is* the gitignored set (`git ls-files +--ignored`), so it can never drift from `.gitignore`. + +- **On demand:** `bin/backup` (or `bin/ogmactl backup`, so the bot can trigger one over Telegram). +- **Scheduled:** `systemctl --user enable --now ogma-backup.timer` (nightly ~03:30, notifies on + completion). `bin/setup` installs the unit automatically. +- **Where:** archives land **outside** the repo — `~/ogma-backups/` by default (override with + `--out DIR` or `OGMA_BACKUP_DIR`) — so they survive `git pull`, reinstalls, and uninstall. + Each is a `chmod 600` `.tar.gz` (it contains `.env`). List them with `bin/backup --list`. +- **Retention:** keeps the newest 14 (`--keep N` or `OGMA_BACKUP_KEEP`; `0` = keep all). + +To restore on a fresh box: re-clone, `tar xzf ` into the repo, then `bin/setup`. + +## Uninstall +```bash +bin/uninstall +``` +It backs up your host-local files first (unless `--no-backup`), stops and removes the +systemd `--user` units and the copied skills, and then **deletes the Ogma directory itself** +(the script `exec`s `rm` so the repo can erase the very script that's running — no npx needed). +System-level units (e.g. `ogma-pihole-watch`) need root, so it prints the `sudo` commands for +you to run rather than touching them. **Never deleted:** your Claude memory directory and the +backup archives. Flags: `-y/--yes`, `--no-backup`, `--backup-dir DIR`, `--keep-skills`. ## What's not included / known limitations - **Single brain.** The persona, workspace, and memory are shared — adding several chat IDs to the diff --git a/bin/backup b/bin/backup new file mode 100755 index 0000000..63158af --- /dev/null +++ b/bin/backup @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# backup — archive this host's local Ogma files into a timestamped tarball. +# +# The host-local surface is exactly the git-ignored set (.env, logs, state/, tickets, +# memory-backups/, the presence DB, and any host-local bin/config extensions). Nothing +# host-specific is tracked, so `git ls-files --ignored` IS the manifest — backup and +# .gitignore can never drift apart. +# +# Archives land OUTSIDE the repo (default ~/ogma-backups) so they survive `git pull`, +# a reinstall, and `bin/uninstall`. Run on demand, from the Telegram bot (ogmactl +# backup), or on a schedule (systemd/ogma-backup.timer). +set -uo pipefail + +OGMA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Colors only on a terminal — keep journald/Telegram output clean. +if [ -t 1 ]; then + c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_red=$'\033[31m'; c_rst=$'\033[0m' +else + c_bold=''; c_dim=''; c_grn=''; c_yel=''; c_red=''; c_rst='' +fi +say() { printf '%s\n' "$*"; } +ok() { printf '%s ✓ %s%s\n' "$c_grn" "$*" "$c_rst"; } +warn() { printf '%s ! %s%s\n' "$c_yel" "$*" "$c_rst" >&2; } +err() { printf '%s ✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; } + +OUT="${OGMA_BACKUP_DIR:-$HOME/ogma-backups}" +KEEP="${OGMA_BACKUP_KEEP:-14}" +NOTIFY=0 +ACTION="create" + +usage() { + cat </dev/null | cut -f1)" "$(basename "$f")" + done + exit 0 +fi + +# --- Build the manifest (NUL-delimited): git-ignored + untracked host files. ---------- +# Bash variables can't hold NUL bytes, so the manifest lives in a temp file we feed +# straight to `tar --null -T`. +mf="$(mktemp "${TMPDIR:-/tmp}/ogma-backup-manifest.XXXXXX")" +trap 'rm -f "$mf"' EXIT +if git -C "$OGMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then + { + git -C "$OGMA_DIR" ls-files --others --ignored --exclude-standard -z + git -C "$OGMA_DIR" ls-files --others --exclude-standard -z + } | grep -zvE '(^|/)(\.venv|__pycache__|\.git)/' | grep -zvE '\.pyc$' | sort -zu > "$mf" +else + # Not a git checkout (e.g. a tarball download): fall back to the known host-local + # paths from .gitignore. Globs that match nothing expand to nothing below. + warn "not a git checkout — using static host-local path list" + fallback=( + .env sessions.json *.log state memory-backups + tickets bin/ogmactl.local config/commands.local.json + bin/pihole-watch config/watched-domains.txt systemd/ogma-pihole-watch.service + workspace/CLAUDE.local.md workspace/.claude/settings.local.json + bin/fritz-poc bin/presence bin/_fritz.py bin/_arp.py + config/people.yaml config/people.yaml.bak + config/people-enrolled.tsv config/people-enrolled.tsv.bak config/presence.db + ) + pushd "$OGMA_DIR" >/dev/null || { err "cannot enter $OGMA_DIR"; exit 1; } + for p in "${fallback[@]}"; do + for m in $p; do [ -e "$m" ] && printf '%s\0' "$m"; done + done > "$mf" + popd >/dev/null +fi + +if [ ! -s "$mf" ]; then + warn "no host-local files found to back up — nothing to do." + [ "$NOTIFY" = 1 ] && printf '🗄️ Ogma backup: nothing to archive (no host-local files).' | "$OGMA_DIR/bin/tg-send" 2>/dev/null + exit 0 +fi + +count="$(grep -zc '' "$mf" 2>/dev/null || echo '?')" + +# --- Create the archive ---------------------------------------------------------------- +mkdir -p "$OUT" || { err "cannot create $OUT"; exit 1; } +host="$(hostname -s 2>/dev/null || echo host)" +ts="$(date +%Y%m%d-%H%M%S)" +archive="$OUT/ogma-backup-${host}-${ts}.tar.gz" + +if tar -C "$OGMA_DIR" --null -czf "$archive" -T "$mf" 2>/dev/null; then + chmod 600 "$archive" 2>/dev/null || true # contains .env — keep it private + size="$(du -h "$archive" 2>/dev/null | cut -f1)" + ok "Backed up $count file(s) → $archive ($size)" + result="🗄️ Ogma backup OK — ${count} files, ${size:-?} → $(basename "$archive")" +else + err "tar failed — archive not written." + [ "$NOTIFY" = 1 ] && printf '⚠️ Ogma backup FAILED on %s (tar error).' "$host" | "$OGMA_DIR/bin/tg-send" 2>/dev/null + rm -f "$archive" 2>/dev/null + exit 1 +fi + +# --- Retention: prune to the newest $KEEP archives ------------------------------------- +if (( KEEP > 0 )); then + mapfile -t all < <(list_archives) + if (( ${#all[@]} > KEEP )); then + prune=$(( ${#all[@]} - KEEP )) + for ((i=0; i/dev/null +exit 0 diff --git a/bin/ogmactl b/bin/ogmactl index 886def8..ebd0c5c 100755 --- a/bin/ogmactl +++ b/bin/ogmactl @@ -17,7 +17,7 @@ TICKETS="$BASE/tickets" LOCAL="$BASE/bin/ogmactl.local" usage() { - echo "ogmactl: allowed commands — status | logs [source] [N] | restart | health | remember [--type T] | set-persona [value] | ticket | tickets" + echo "ogmactl: allowed commands — status | logs [source] [N] | restart | health | backup | remember [--type T] | set-persona [value] | ticket | tickets" echo " log sources: gateway (default), dream, briefing" echo " remember types: user | feedback | project (default) | reference" echo " set-persona fields: name | style | language | show | clear" @@ -80,6 +80,14 @@ case "$cmd" in echo "memory : $(free -h | awk 'NR==2{print $3" used / "$2}')" echo "disk / : $(df -h / | awk 'NR==2{print $3" used / "$2" ("$5")"}')" ;; + backup) + # Let the bot (or operator) trigger an on-demand host-local backup. Delegates to + # bin/backup, which archives the git-ignored host files to ~/ogma-backups (outside + # the repo). Uninstall is deliberately NOT exposed here — backups only. + bk="$BASE/bin/backup" + [ -x "$bk" ] || { echo "backup helper missing ($bk)"; exit 1; } + exec "$bk" + ;; remember) # Let the bot persist a memory immediately (and confirm in its reply). Writing via plain # shell sidesteps Claude Code's "sensitive path" tool guard that blocks the gateway (cwd= diff --git a/bin/setup b/bin/setup index 19ff9e2..35ec1c9 100755 --- a/bin/setup +++ b/bin/setup @@ -63,6 +63,7 @@ print(("OK "+((d.get("result") or {}).get("username") or "?")) if d.get("ok") el fi if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then printf ' service : %s\n' "$(systemctl --user is-active ogma-gateway 2>/dev/null || echo inactive)" + printf ' backup : %s\n' "$(systemctl --user is-active ogma-backup.timer 2>/dev/null || echo inactive)" fi for s in tickets session-search daily-briefing; do [ -e "$HOME/.claude/skills/$s" ] && ok "skill: $s" || warn "skill not installed: $s" @@ -262,7 +263,7 @@ if [ "$HAVE_SYSTEMD" = 1 ]; then if yes "Enable linger (keep services running when you're logged out)?" "y"; then loginctl enable-linger "$USER" 2>/dev/null && ok "Linger enabled." || warn "Could not enable linger (may need: sudo loginctl enable-linger $USER)." fi - say "${c_dim}Optional routines: systemctl --user enable --now ogma-briefing.timer ogma-dream.timer ogma-health.timer${c_rst}" + say "${c_dim}Optional routines: systemctl --user enable --now ogma-briefing.timer ogma-dream.timer ogma-health.timer ogma-backup.timer${c_rst}" else warn "Skipped. Templates are in systemd/ — install them manually later (see README)." fi diff --git a/bin/uninstall b/bin/uninstall new file mode 100755 index 0000000..68dd533 --- /dev/null +++ b/bin/uninstall @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# uninstall — remove everything bin/setup installed, then erase the repo itself. +# +# Mirror of bin/setup. By default it backs up your host-local files FIRST (to +# ~/ogma-backups, outside the repo, via bin/backup), then stops + removes the systemd +# --user units and the copied skills, and finally deletes the Ogma directory. +# +# Self-destruct: the last line is `exec rm -rf "$OGMA_DIR"`. exec replaces this shell +# with /usr/bin/rm — a binary that lives OUTSIDE the repo — so deleting the repo (this +# script included) is safe; nothing is left reading from the directory being removed. +# +# What it NEVER touches: your Claude memory dir (~/.claude/projects/.../memory) and the +# backup archives. Both survive the uninstall. +set -uo pipefail + +OGMA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_red=$'\033[31m'; c_rst=$'\033[0m' +say() { printf '%s\n' "$*"; } +step() { printf '\n%s==> %s%s\n' "$c_bold" "$*" "$c_rst"; } +ok() { printf '%s ✓ %s%s\n' "$c_grn" "$*" "$c_rst"; } +warn() { printf '%s ! %s%s\n' "$c_yel" "$*" "$c_rst"; } +err() { printf '%s ✗ %s%s\n' "$c_red" "$*" "$c_rst"; } +ask() { local p="$1" d="${2:-}" a; if [ -n "$d" ]; then read -r -p "$p [$d]: " a || true; printf '%s' "${a:-$d}"; else read -r -p "$p: " a || true; printf '%s' "$a"; fi; } + +ASSUME_YES=0; DO_BACKUP=1; KEEP_SKILLS=0; BACKUP_DIR="" +usage() { + cat </dev/null && systemctl --user show-environment >/dev/null 2>&1 && HAVE_SYSTEMD=1 +shopt -s nullglob +user_units=("$UD"/ogma-*.service "$UD"/ogma-*.timer) +sys_units=(/etc/systemd/system/ogma-*.service /etc/systemd/system/ogma-*.timer) +SKILLS=(tickets session-search daily-briefing) +present_skills=() +for s in "${SKILLS[@]}"; do [ -e "$HOME/.claude/skills/$s" ] && present_skills+=("$s"); done + +step "This will remove" +say " • the Ogma directory: ${c_bold}$OGMA_DIR${c_rst}" +[ "$DO_BACKUP" = 1 ] && say " ${c_dim}(after backing up host-local files to ${BACKUP_DIR:-${OGMA_BACKUP_DIR:-$HOME/ogma-backups}})${c_rst}" \ + || say " ${c_yel}(WITHOUT a backup — --no-backup given)${c_rst}" +if (( ${#user_units[@]} )); then say " • systemd --user units:"; for u in "${user_units[@]}"; do say " $(basename "$u")"; done; fi +if (( ${#present_skills[@]} )); then + if [ "$KEEP_SKILLS" = 1 ]; then say " • skills: ${c_dim}kept (--keep-skills): ${present_skills[*]}${c_rst}" + else say " • skills in ~/.claude/skills/: ${present_skills[*]}"; fi +fi +say "${c_dim}Preserved: your Claude memory dir and the backup archives.${c_rst}" + +if [ "$ASSUME_YES" != 1 ]; then + ans="$(ask "Proceed?" "n")" + [[ "$ans" =~ ^[Yy] ]] || { say "Aborted — nothing changed."; exit 0; } +fi + +# --- 1. Backup host-local files ---------------------------------------------- +if [ "$DO_BACKUP" = 1 ]; then + step "Backing up host-local files" + bk="$OGMA_DIR/bin/backup" + if [ -x "$bk" ]; then + backup_args=(); [ -n "$BACKUP_DIR" ] && backup_args=(--out "$BACKUP_DIR") + if "$bk" "${backup_args[@]}"; then ok "Backup complete." + else err "Backup failed — aborting uninstall so nothing is lost."; exit 1; fi + else + err "bin/backup not found — aborting (re-run with --no-backup to skip)."; exit 1 + fi +fi + +# --- 2. systemd --user units ------------------------------------------------- +if (( ${#user_units[@]} )); then + step "Removing systemd --user units" + for u in "${user_units[@]}"; do + base="$(basename "$u")" + [ "$HAVE_SYSTEMD" = 1 ] && systemctl --user disable --now "$base" >/dev/null 2>&1 || true + rm -f "$u" && ok "removed $base" + done + [ "$HAVE_SYSTEMD" = 1 ] && systemctl --user daemon-reload 2>/dev/null || true + [ "$HAVE_SYSTEMD" = 1 ] && systemctl --user reset-failed 2>/dev/null || true +else + say "${c_dim}No systemd --user units to remove.${c_rst}" +fi + +# --- 3. System units (need root — instruct, don't sudo silently) ------------- +if (( ${#sys_units[@]} )); then + step "System units found (need root to remove)" + warn "Run these yourself to finish removing the system-level units:" + for u in "${sys_units[@]}"; do + base="$(basename "$u")" + say " ${c_dim}sudo systemctl disable --now $base${c_rst}" + say " ${c_dim}sudo rm $u${c_rst}" + done +fi + +# --- 4. Skills --------------------------------------------------------------- +if (( ${#present_skills[@]} )) && [ "$KEEP_SKILLS" != 1 ]; then + step "Removing skills from ~/.claude/skills/" + for s in "${present_skills[@]}"; do + dest="$HOME/.claude/skills/$s" + if [ -L "$dest" ]; then rm -f "$dest" && ok "unlinked $s" + else rm -rf "$dest" && ok "removed $s"; fi + done +fi + +# --- 5. Note what survives --------------------------------------------------- +mem_dir="$HOME/.claude/projects/$(printf '%s' "$HOME" | sed 's#/#-#g')/memory" +step "Preserved (not touched)" +[ -d "$mem_dir" ] && say " • Claude memory : $mem_dir" +say " • Backups : ${BACKUP_DIR:-${OGMA_BACKUP_DIR:-$HOME/ogma-backups}}" + +# --- 6. Self-destruct -------------------------------------------------------- +step "Removing the Ogma directory" +say "${c_dim}Goodbye. (exec rm -rf $OGMA_DIR)${c_rst}" +cd / # leave the dir we're about to delete +exec rm -rf -- "$OGMA_DIR" diff --git a/systemd/ogma-backup.service b/systemd/ogma-backup.service new file mode 100644 index 0000000..dec6615 --- /dev/null +++ b/systemd/ogma-backup.service @@ -0,0 +1,6 @@ +[Unit] +Description=Ogma host-local backup (archive .env/state/tickets/etc. -> ~/ogma-backups) + +[Service] +Type=oneshot +ExecStart={{OGMA_DIR}}/bin/backup --notify diff --git a/systemd/ogma-backup.timer b/systemd/ogma-backup.timer new file mode 100644 index 0000000..a96e276 --- /dev/null +++ b/systemd/ogma-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run the Ogma host-local backup nightly at ~03:30 + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target