# `--suggest` Flag Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a read-only `-s|--suggest` flag to `dot-backup.sh` that lists untracked config dirs/files created since the last backup and prints a paste-ready `files.list` block. **Architecture:** A new `do_suggest()` function scans `~/.config/*`, `~/.*` dirs, and `~/.*` files one level deep, filters by mtime-vs-last-backup, dedup against `DOTFILES`, and a `SUGGEST_IGNORE` basename skip list. The `lastupdate()` function gains a machine-readable `lastupdate.epoch` sibling file for the mtime comparison. Suggest dispatches early (after the FIRST_RUN guard, before restore/backup) and always writes to real stdout, bypassing `--quiet`. **Tech Stack:** Pure bash, coreutils (`date`, `stat`, `sort`). No test framework in repo — verification is manual via documented commands. **Spec:** `docs/superpowers/specs/2026-06-16-suggest-flag-design.md` **Note on TDD:** This repo has no test harness (CLAUDE.md: "No tests"). Each task uses manual verification commands with expected output in place of automated tests. Run them exactly; they are the acceptance check. --- ## File Structure - `dot-backup.sh` — all logic. New flag var, arg case, usage line; quiet-redirect guard; `lastupdate()` modification; `SUGGEST_IGNORE` default; `do_suggest()` function; early dispatch. - `config.example` — commented `SUGGEST_IGNORE` example. - `README.md` — document `--suggest` in usage/options. - `CLAUDE.md` — note the flag and `lastupdate.epoch`. All current line numbers are from the working tree at plan time and may drift as edits land — match on the quoted code, not the number. --- ## Task 1: Flag plumbing (var, arg case, usage) **Files:** - Modify: `dot-backup.sh` (flag block ~line 49-54, `usage()` ~line 56-65, arg loop ~line 67-77) - [ ] **Step 1: Add the flag variable** In the flags block, after `PUSH=false`, add: ```bash SUGGEST=false ``` Result block: ```bash # Flags DRY_RUN=false VERBOSE=false RESTORE=false QUIET=false PUSH=false SUGGEST=false ``` - [ ] **Step 2: Add the usage line** In `usage()`, after the `--push` line and before `--help`, add: ```bash echo " -s, --suggest List untracked config dirs/files new since last backup, then exit" ``` - [ ] **Step 3: Add the arg case** In the `for arg in "$@"` case statement, after the `-p|--push` line, add: ```bash -s|--suggest) SUGGEST=true ;; ``` - [ ] **Step 4: Verify the flag parses and help shows it** Run: `bash -n dot-backup.sh && ./dot-backup.sh --help` Expected: no syntax error; help output includes the `-s, --suggest` line. - [ ] **Step 5: Commit** ```bash git add dot-backup.sh git commit -m "feat: add --suggest flag plumbing" ``` --- ## Task 2: Guard the quiet redirect so suggest stays on stdout **Files:** - Modify: `dot-backup.sh` (quiet redirect block ~line 79-84) - [ ] **Step 1: Update the quiet guard** Change: ```bash if [[ "$QUIET" == true ]]; then ``` to: ```bash if [[ "$QUIET" == true && "$SUGGEST" == false ]]; then ``` The rest of the block (fd 3 save, redirect) is unchanged. `SUGGEST` is parsed in the arg loop above this block, so it is known here. - [ ] **Step 2: Verify redirect is skipped under suggest** Run: `./dot-backup.sh --suggest --quiet 2>&1 | head -1` Expected: output appears on the terminal (not silently swallowed to log). Exact content is implemented in Task 5; for now it may error on a not-yet-defined function — that is fine, the point is stdout is NOT redirected. If `do_suggest` is undefined at this stage, expect a "command not found" on stdout, which still proves the redirect was skipped. > Note: if running tasks strictly in order, `do_suggest` does not exist yet. You may defer this verification to after Task 5, or temporarily observe the "command not found" lands on the terminal. Either confirms the guard. - [ ] **Step 3: Commit** ```bash git add dot-backup.sh git commit -m "feat: suggest bypasses --quiet redirect" ``` --- ## Task 3: Write `lastupdate.epoch` alongside human timestamp **Files:** - Modify: `dot-backup.sh` (`lastupdate()` ~line 184-187) - [ ] **Step 1: Modify `lastupdate()`** Change: ```bash lastupdate() { D=$(date) echo "Last update: $D" > "${DEFAULT_OUTPUT_DIR}/lastupdate" } ``` to: ```bash lastupdate() { D=$(date) echo "Last update: $D" > "${DEFAULT_OUTPUT_DIR}/lastupdate" date +%s > "${DEFAULT_OUTPUT_DIR}/lastupdate.epoch" } ``` - [ ] **Step 2: Verify both files written by a dry-run-free path** `lastupdate()` is only called in the real backup flow (not dry-run). Verify in isolation: Run: ```bash DEFAULT_OUTPUT_DIR=$(mktemp -d); export DEFAULT_OUTPUT_DIR bash -c 'source <(sed -n "184,188p" dot-backup.sh); DEFAULT_OUTPUT_DIR="'"$DEFAULT_OUTPUT_DIR"'"; lastupdate; cat "$DEFAULT_OUTPUT_DIR/lastupdate"; echo "epoch=$(cat "$DEFAULT_OUTPUT_DIR/lastupdate.epoch")"' ``` Expected: prints `Last update: ` and `epoch=<10-digit integer>`. (If the `sed` line range drifted, adjust to the `lastupdate()` definition lines.) - [ ] **Step 3: Commit** ```bash git add dot-backup.sh git commit -m "feat: lastupdate also writes machine-readable epoch" ``` --- ## Task 4: Add `SUGGEST_IGNORE` default **Files:** - Modify: `dot-backup.sh` (after config source ~line 47, before flags block) - [ ] **Step 1: Add the guarded default** After the config-file `source` block (line ~44-47) and before the `# Flags` block, add: ```bash # Basenames skipped by --suggest. User may override in config. if [[ -z "${SUGGEST_IGNORE+set}" ]]; then SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv \ .mozilla .thunderbird .npm .cargo .rustup .java \ .dbus .config) fi ``` The `${SUGGEST_IGNORE+set}` test is empty only when the variable is wholly unset, so a user who defines an explicit (even empty) array in config is respected. - [ ] **Step 2: Verify default populates and config override wins** Run (default): ```bash bash -c 'source <(sed -n "/Basenames skipped/,/^fi$/p" dot-backup.sh); printf "%s\n" "${SUGGEST_IGNORE[@]}"' | grep -c '^\.' ``` Expected: `16` (count of default entries). Run (override respected): ```bash bash -c 'SUGGEST_IGNORE=(.foo); source <(sed -n "/Basenames skipped/,/^fi$/p" dot-backup.sh); printf "%s\n" "${SUGGEST_IGNORE[@]}"' ``` Expected: prints only `.foo` (default did not overwrite). - [ ] **Step 3: Commit** ```bash git add dot-backup.sh git commit -m "feat: add SUGGEST_IGNORE default skip list" ``` --- ## Task 5: Implement `do_suggest()` **Files:** - Modify: `dot-backup.sh` (add function near other functions, after `do_rsync()` ~line 216, before RESTORE MODE section ~line 218) - [ ] **Step 1: Add the `do_suggest()` function** Insert after the closing `}` of `do_rsync()` and before the `# ── RESTORE MODE` comment: ```bash # ── SUGGEST MODE ────────────────────────────────────────────────────────────── # True if candidate path $1 is already covered by any DOTFILES entry # (exact match, parent of, or child of an existing entry). _suggest_covered() { local c="$1" e for e in "${DOTFILES[@]}"; do # normalize trailing slash e="${e%/}" [[ "$c" == "$e" ]] && return 0 [[ "$c" == "$e"/* ]] && return 0 [[ "$e" == "$c"/* ]] && return 0 done return 1 } # True if basename $1 is in SUGGEST_IGNORE _suggest_ignored() { local b="$1" ig for ig in "${SUGGEST_IGNORE[@]}"; do [[ "$b" == "$ig" ]] && return 0 done return 1 } do_suggest() { local epoch_file="${DEFAULT_OUTPUT_DIR}/lastupdate.epoch" local last_epoch=0 local last_human="never" if [[ -r "$epoch_file" ]]; then local raw raw="$(cat "$epoch_file")" if [[ "$raw" =~ ^[0-9]+$ ]]; then last_epoch="$raw" last_human="$(date -d "@$last_epoch" 2>/dev/null || echo "$last_epoch")" fi fi local -a candidates=() local path emit base mtime # Source 1: ~/.config/* (children of .config) for path in "${HOME}/.config/"*; do [[ -e "$path" ]] || continue base="$(basename "$path")" emit=".config/${base}" _suggest_ignored "$base" && continue _suggest_covered "$emit" && continue mtime="$(stat -c %Y "$path" 2>/dev/null || echo 0)" (( mtime > last_epoch )) && candidates+=("$emit") done # Source 2 + 3: ~/.* (hidden dirs and files at home top level) for path in "${HOME}/".*; do base="$(basename "$path")" [[ "$base" == "." || "$base" == ".." ]] && continue [[ -d "$path" || -f "$path" ]] || continue emit="${base}" _suggest_ignored "$base" && continue _suggest_covered "$emit" && continue mtime="$(stat -c %Y "$path" 2>/dev/null || echo 0)" (( mtime > last_epoch )) && candidates+=("$emit") done if [[ ${#candidates[@]} -eq 0 ]]; then echo -e "${GREEN}No new config dirs/files since last backup.${NC}" return 0 fi # sort alphabetically, stable output local -a sorted=() while IFS= read -r line; do sorted+=("$line"); done < <(printf '%s\n' "${candidates[@]}" | sort -u) echo -e "${GREEN}New since last backup (${last_human}):${NC}" for emit in "${sorted[@]}"; do echo -e " ${BLUE}${emit}${NC}" done echo echo "Paste into files.list:" for emit in "${sorted[@]}"; do echo "$emit" done } ``` - [ ] **Step 2: Verify syntax** Run: `bash -n dot-backup.sh` Expected: no output (clean parse). - [ ] **Step 3: Verify the function runs standalone** The function is not yet dispatched (Task 6). Smoke-test the parse only here; full behavior is verified in Task 6 once wired up. Run: `bash -n dot-backup.sh && echo OK` Expected: `OK`. - [ ] **Step 4: Commit** ```bash git add dot-backup.sh git commit -m "feat: add do_suggest scan/filter/dedup function" ``` --- ## Task 6: Dispatch `--suggest` early and exit **Files:** - Modify: `dot-backup.sh` (MAIN section, after FIRST_RUN guard ~line 309, before DRY_RUN/RESTORE ~line 311-317) - [ ] **Step 1: Add the dispatch block** After the `FIRST_RUN` `if` block (the one ending `exit 0` / `fi` around line 309) and before the `if [[ "$DRY_RUN" == true ]]` block, add: ```bash if [[ "$SUGGEST" == true ]]; then do_suggest exit 0 fi ``` Placing it after FIRST_RUN means a brand-new install (no config yet) shows the first-run message instead of an empty suggest. Placing it before DRY_RUN and RESTORE means suggest takes precedence over those flags, per spec. - [ ] **Step 2: Verify suggest lists and exits without backing up** Run: `./dot-backup.sh --suggest` Expected: either `New since last backup (...)` with an indented list plus a `Paste into files.list:` block, OR `No new config dirs/files since last backup.` No `git` commit output, no `Copied` lines — it exited before the backup flow. - [ ] **Step 3: Verify dedup — a listed path is not suggested** Create a throwaway config dir and confirm it shows, then suppress it: ```bash mkdir -p ~/.config/zzztest touch -d "+1 hour" ~/.config/zzztest # ensure mtime newer than last backup ./dot-backup.sh --suggest | grep zzztest ``` Expected: `.config/zzztest` appears. Now add it to an external list and confirm it disappears: ```bash mkdir -p ~/.config/dot-backup echo ".config/zzztest" >> ~/.config/dot-backup/files.list ./dot-backup.sh --suggest | grep zzztest && echo "STILL THERE (bug)" || echo "deduped OK" ``` Expected: `deduped OK`. Cleanup: ```bash sed -i '/^\.config\/zzztest$/d' ~/.config/dot-backup/files.list rmdir ~/.config/zzztest ``` (If `files.list` is now empty and was created only for this test, remove it.) - [ ] **Step 4: Verify ignore list suppresses a basename** ```bash mkdir -p ~/.config/zzzignore; touch -d "+1 hour" ~/.config/zzzignore ./dot-backup.sh --suggest | grep zzzignore ``` Expected: `.config/zzzignore` appears (not yet ignored). Add to config and re-run: ```bash echo 'SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv .mozilla .thunderbird .npm .cargo .rustup .java .dbus .config zzzignore)' >> ~/.config/dot-backup/config ./dot-backup.sh --suggest | grep zzzignore && echo "STILL THERE (bug)" || echo "ignored OK" ``` Expected: `ignored OK`. Cleanup: remove the added `SUGGEST_IGNORE=` line from `~/.config/dot-backup/config` and `rmdir ~/.config/zzzignore`. - [ ] **Step 5: Verify suggest survives `--quiet` (stdout, not log)** Run: `./dot-backup.sh --suggest --quiet` Expected: output appears on terminal (the Task 2 guard works end-to-end). The log file is not written for this invocation. - [ ] **Step 6: Commit** ```bash git add dot-backup.sh git commit -m "feat: dispatch --suggest early and exit" ``` --- ## Task 7: Document in config.example, README, CLAUDE.md **Files:** - Modify: `config.example` - Modify: `README.md` - Modify: `CLAUDE.md` - [ ] **Step 1: Add SUGGEST_IGNORE to config.example** Append to `config.example`: ```bash # Basenames --suggest skips when scanning for new config (override the # baked-in default). Uncomment and edit to customize. #SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv \ # .mozilla .thunderbird .npm .cargo .rustup .java \ # .dbus .config) ``` - [ ] **Step 2: Document the flag in README.md** Find the options/usage list in `README.md` and add an entry matching the existing format for the other flags: ``` -s, --suggest List untracked config dirs/files new since last backup, then exit ``` If README has a prose section per flag, add a short paragraph: > `--suggest` scans `~/.config` children and top-level hidden dirs/files in > `$HOME`, and prints any that are newer than the last backup and not already > tracked, formatted for pasting into `files.list`. It never copies or commits. > Tune what it skips with `SUGGEST_IGNORE` in the config file. Match the README's actual heading/format; do not invent a new section style. - [ ] **Step 3: Note the flag and epoch file in CLAUDE.md** In `CLAUDE.md`, add `--suggest` to the behavior description and note the new file. Suggested addition under the architecture/running section: > `--suggest` lists untracked config dirs/files newer than the last backup > (read-only; prints a `files.list` paste block, then exits). The backup writes > a machine-readable `lastupdate.epoch` (epoch seconds) next to `lastupdate`; > `--suggest` reads it to decide what counts as "new". - [ ] **Step 4: Verify docs reference the real flag** Run: `grep -l 'suggest' config.example README.md CLAUDE.md` Expected: all three filenames listed. - [ ] **Step 5: Commit** ```bash git add config.example README.md CLAUDE.md git commit -m "docs: document --suggest flag and SUGGEST_IGNORE" ``` --- ## Final verification - [ ] `bash -n dot-backup.sh` — clean parse. - [ ] `./dot-backup.sh --help` — shows `-s, --suggest`. - [ ] `./dot-backup.sh --suggest` — lists or reports none, exits without backup. - [ ] `./dot-backup.sh --suggest --quiet` — output on terminal, not log. - [ ] A real backup run (`./dot-backup.sh`) writes both `lastupdate` and `lastupdate.epoch` in `$DEFAULT_OUTPUT_DIR`. - [ ] After a real backup, `--suggest` list shrinks to only paths newer than that backup.