diff --git a/CHANGELOG.md b/CHANGELOG.md
index 214b95c..46ab88e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
+## [0.2.1.0] - 2026-06-05
+
+### Fixed
+
+- **`secrets rekey` no longer bricks dotenv stores.** The re-encrypt loop used a bare `"$dir"*` glob, which never matches dotfiles — so `.env` blobs were decrypted to the temp dir but never re-encrypted, leaving them on the **old** key while the new key overwrote `key.txt`. After a rotation, every dotenv file in the store was undecryptable. The glob now mirrors the decrypt loop (`"$dir"* "$dir".*`), and a round-trip test (push → rekey → pull) pins it. If you ran `rekey` on an earlier version and `pull` now fails with `no identity matched any of the recipients`, your blobs are on a pre-rotation key — recover with an old `key.txt` from another machine.
+- **`secrets init` on a second machine now fails helpfully instead of half-initializing.** Copying `key.txt` into `~/.secrets` and then running `init` (instead of cloning your secrets repo) used to run `git init`, crash on the existing key, and leave a store with no `.gitignore` — a state where a later `push` would commit the private key. The guard now fires *before* `git init`, leaves the key untouched, and prints the exact `git clone` command to run — using the real remote URL when your `.secrets-store` file declares one.
+
+### Security
+
+- **The private key can no longer be committed by a store missing its `.gitignore`.** `push`, `pull`, and `rekey` now self-heal store protections immediately before any `git add -A`: a missing *or corrupted* `.gitignore` (one without the `key.txt` line) is rewritten, and the pre-commit hook is reinstalled if absent. The heal runs *after* the fast-forward pull, closing a window where remote history without a `.gitignore` could strip protection mid-push.
+- **An already-tracked `key.txt` is now untracked automatically.** `.gitignore` can't untrack a file that was committed in the past; the heal now removes a tracked key from the index with a warning that history may need scrubbing and the key may warrant rotation.
+
+### Changed
+
+- Project `CLAUDE.md` gained agent skill-routing guidance and an updated test-suite count (126 bats tests, up from 113).
+
## [0.2.0.0] - 2026-05-26
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 2a5985d..0a1a49f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
secrets # CLI script (~600 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
- secrets.bats # bats-core test suite (113 tests)
+ secrets.bats # bats-core test suite (126 tests)
test_helper.bash # Shared setup/teardown
README.md # User-facing documentation
CLAUDE.md # This file
@@ -85,3 +85,22 @@ Key design decisions (all driven by /autoplan review):
## Environment variable
`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store
` for one-shot overrides that beat everything.
+
+## Skill routing
+
+When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
+
+Key routing rules:
+- Product ideas/brainstorming → invoke /office-hours
+- Strategy/scope → invoke /plan-ceo-review
+- Architecture → invoke /plan-eng-review
+- Design system/plan review → invoke /design-consultation or /plan-design-review
+- Full review pipeline → invoke /autoplan
+- Bugs/errors → invoke /investigate
+- QA/testing site behavior → invoke /qa or /qa-only
+- Code review/diff check → invoke /review
+- Visual polish → invoke /design-review
+- Ship/deploy/PR → invoke /ship or /land-and-deploy
+- Save progress → invoke /context-save
+- Resume context → invoke /context-restore
+- Author a backlog-ready spec/issue → invoke /spec
diff --git a/README.md b/README.md
index 2e23b41..d594f57 100644
--- a/README.md
+++ b/README.md
@@ -404,13 +404,15 @@ If you suspect your key has been compromised, or a teammate leaves the team:
secrets rekey
```
-This generates a new key and re-encrypts all secrets. After rekeying:
+This generates a new key and re-encrypts all secrets (including `.env` and other dotfiles). After rekeying:
1. Copy the new `~/.secrets/key.txt` to every machine and teammate
2. Old encrypted files remain in git history (encrypted with the old key, which should be discarded)
For complete rotation with no historical exposure, create a fresh `~/.secrets/` repo.
+> **Recovering from a broken rekey (pre-0.2.1.0):** Older versions of `rekey` skipped dotfiles (`.env`, `.dev.vars`) when re-encrypting, leaving their blobs on the *old* key while `key.txt` was replaced. If `secrets pull` now fails with `no identity matched any of the recipients`, those blobs are still encrypted to a key you no longer have. Restore the **old** `key.txt` from another machine that hasn't rekeyed, `secrets pull` to recover the plaintext, then `secrets rekey` again on 0.2.1.0 or later.
+
## Environment variables
| Variable | Default | Purpose |
@@ -423,6 +425,8 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
**"Not initialized"** — Run `secrets init` to create the `~/.secrets/` directory.
+**"Found an existing key ... but no repo"** — You copied `key.txt` into `~/.secrets` and then ran `secrets init`. On a second machine you should *clone* your existing secrets repo, not re-initialize it (`init` is only for the very first machine). The error prints the exact `git clone` command to run — copy-paste it, or see [Additional machines](#additional-machines). When your project's `.secrets-store` file declares a remote URL, the command is filled in with the real URL.
+
**"No secret files found"** — You're in a directory that doesn't have `.env`, `.env.*`, or `.dev.vars` files. Make sure you're in the right project directory.
**"Project not found"** — The project name doesn't match anything in `~/.secrets/`. Run `secrets list` to see what's stored. The name is usually derived from your directory name or git remote.
@@ -432,7 +436,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
## Development
```bash
-# Run the test suite (113 tests)
+# Run the test suite (126 tests)
brew install bats-core
bats test/secrets.bats
```
diff --git a/VERSION b/VERSION
index e396b40..123b55d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.0.0
+0.2.1.0
diff --git a/secrets b/secrets
index 76eb7c4..97d9640 100755
--- a/secrets
+++ b/secrets
@@ -701,26 +701,9 @@ HOOKEOF
fi
}
-# ─── Subcommands ───────────────────────────────────────────────────────
-
-cmd_init() {
- check_cmd age
- check_cmd git
- resolve_store
-
- if [ -d "$SECRETS_DIR/.git" ]; then
- die "Already initialized at $SECRETS_DIR. Key file preserved."
- fi
-
- info "Initializing secrets repo at $SECRETS_DIR"
- mkdir -p "$SECRETS_DIR"
- git init "$SECRETS_DIR" >/dev/null
-
- # Generate age key pair
- info "Generating age key pair"
- age-keygen -o "$KEY_FILE" 2>&1
-
- # Write .gitignore
+# Write the store-level .gitignore. Critical: the `key.txt` line is what
+# keeps the private key out of `git add -A` during push/rekey.
+write_store_gitignore() {
cat > "$SECRETS_DIR/.gitignore" << 'EOF'
# Never commit the private key
key.txt
@@ -735,6 +718,70 @@ key.txt
!**/.env.*.age
!**/.dev.vars.age
EOF
+}
+
+# Restore store-level protections if missing. A cloned store has no
+# pre-commit hook (hooks aren't cloned), and a half-initialized store may
+# lack .gitignore — without it, `git add -A` would commit key.txt.
+ensure_store_protections() {
+ # Content-aware: a present-but-corrupted .gitignore missing the key.txt
+ # line is just as dangerous as a missing one.
+ if [ ! -f "$SECRETS_DIR/.gitignore" ] || ! grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"; then
+ write_store_gitignore
+ info "Restored store .gitignore"
+ fi
+ # .gitignore can't untrack an already-tracked key (legacy damage, or a
+ # past window where .gitignore was missing). Remove it from the index so
+ # the next commit drops it from the tip.
+ if git -C "$SECRETS_DIR" ls-files --error-unmatch key.txt >/dev/null 2>&1; then
+ git -C "$SECRETS_DIR" rm --cached --quiet key.txt
+ echo "WARNING: key.txt was tracked in the store repo — untracked it now." >&2
+ echo "It may still exist in git history; consider 'secrets rekey' and scrubbing history." >&2
+ fi
+ if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
+ mkdir -p "$SECRETS_DIR/.git/hooks"
+ install_hook
+ info "Reinstalled pre-commit hook"
+ fi
+}
+
+# ─── Subcommands ───────────────────────────────────────────────────────
+
+cmd_init() {
+ check_cmd age
+ check_cmd git
+ resolve_store
+
+ if [ -d "$SECRETS_DIR/.git" ]; then
+ die "Already initialized at $SECRETS_DIR. Key file preserved."
+ fi
+
+ # Second-machine trap: a copied key.txt without a repo means the user
+ # should clone their existing secrets repo, not init a fresh one.
+ # Catch it BEFORE git init so we don't leave a half-initialized store.
+ if [ -f "$KEY_FILE" ]; then
+ # Render a runnable clone command when .secrets-store carried a remote
+ # URL (already sanitized by resolve_store), mirroring check_initialized.
+ local clone_src=""
+ [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
+ die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR.
+If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead:
+
+ git clone $clone_src $SECRETS_DIR
+
+Your key file has been left untouched."
+ fi
+
+ info "Initializing secrets repo at $SECRETS_DIR"
+ mkdir -p "$SECRETS_DIR"
+ git init "$SECRETS_DIR" >/dev/null
+
+ # Generate age key pair
+ info "Generating age key pair"
+ age-keygen -o "$KEY_FILE" 2>&1
+
+ # Write .gitignore
+ write_store_gitignore
# Install pre-commit hook
mkdir -p "$SECRETS_DIR/.git/hooks"
@@ -787,6 +834,11 @@ commit_and_push_secrets() {
fi
fi
+ # Must run AFTER the pull and immediately before `git add -A`: the pull
+ # can remove or alter .gitignore (remote history that lacks it), and a
+ # store missing the key.txt line would stage and push the private key.
+ ensure_store_protections
+
git -C "$SECRETS_DIR" add -A
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
info "No changes to push (secrets unchanged)"
@@ -917,11 +969,8 @@ cmd_pull() {
# Merge any external files (.secrets-files) declared in this project.
pull_external_files "$PWD" "$project"
- # Reinstall hook if missing
- if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
- install_hook
- info "Reinstalled pre-commit hook"
- fi
+ # Reinstall hook / store .gitignore if missing
+ ensure_store_protections
}
# Pull and decrypt .age files from a project path into a target directory.
@@ -1004,11 +1053,8 @@ cmd_pull_workspaces() {
info "Decrypted $total file(s) total"
- # Reinstall hook if missing
- if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
- install_hook
- info "Reinstalled pre-commit hook"
- fi
+ # Reinstall hook / store .gitignore if missing
+ ensure_store_protections
}
cmd_list() {
@@ -1139,13 +1185,15 @@ cmd_rekey() {
info "Re-encrypting all files with new key..."
- # Re-encrypt all files
+ # Re-encrypt all files. The ".*" glob is required: dotenv files decrypt
+ # to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip,
+ # leaving their blobs on the old key (undecryptable after rotation).
for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue
local project
project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project"
- for f in "$dir"*; do
+ for f in "$dir"* "$dir".*; do
[ -f "$f" ] || continue
local name
name=$(basename "$f")
@@ -1162,7 +1210,8 @@ cmd_rekey() {
fi
done
- # Commit and push
+ # Commit and push (heal .gitignore first so add -A can't stage key.txt)
+ ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
diff --git a/test/secrets.bats b/test/secrets.bats
index 2119c40..f5565aa 100644
--- a/test/secrets.bats
+++ b/test/secrets.bats
@@ -1476,3 +1476,184 @@ gradle_project() {
# definition must, and the '!' comment must be ignored.
grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties"
}
+
+# ─── init second-machine guard + store .gitignore self-heal ────────────
+
+@test "init with existing key but no repo dies with clone guidance" {
+ # Second-machine trap: user copies key.txt into ~/.secrets, then runs
+ # `secrets init` instead of cloning their secrets repo.
+ mkdir -p "$SECRETS_DIR"
+ age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
+ # Guard against a vacuous '' = '' comparison if age-keygen failed
+ [ -s "$SECRETS_DIR/key.txt" ]
+ local key_before
+ key_before=$(cat "$SECRETS_DIR/key.txt")
+
+ run "$SECRETS_BIN" init
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"git clone"* ]]
+ # Must not leave a half-initialized store behind
+ [ ! -d "$SECRETS_DIR/.git" ]
+ # Key untouched
+ [ "$(cat "$SECRETS_DIR/key.txt")" = "$key_before" ]
+}
+
+@test "push restores missing store .gitignore and never commits key.txt" {
+ init_with_remote
+ rm "$SECRETS_DIR/.gitignore"
+ create_project_dir
+
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Restored store .gitignore"* ]]
+ [ -f "$SECRETS_DIR/.gitignore" ]
+ grep -q "key.txt" "$SECRETS_DIR/.gitignore"
+ # key.txt must never be tracked (push does `git add -A` in the store)
+ run git -C "$SECRETS_DIR" ls-files
+ [[ "$output" != *"key.txt"* ]]
+}
+
+@test "pull restores missing store .gitignore" {
+ init_with_remote
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+ rm "$SECRETS_DIR/.gitignore"
+ rm .env .env.staging
+
+ run "$SECRETS_BIN" pull
+ [ "$status" -eq 0 ]
+ [ -f "$SECRETS_DIR/.gitignore" ]
+}
+
+@test "rekey restores missing store .gitignore and never commits key.txt" {
+ init_with_remote
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+ rm "$SECRETS_DIR/.gitignore"
+
+ run "$SECRETS_BIN" rekey
+ [ "$status" -eq 0 ]
+ [ -f "$SECRETS_DIR/.gitignore" ]
+ run git -C "$SECRETS_DIR" ls-files
+ [[ "$output" != *"key.txt"* ]]
+}
+
+@test "rekey re-encrypts dotenv blobs (round-trip survives key rotation)" {
+ # The decrypt loop matches dotfiles (".*.age") but a re-encrypt glob of
+ # "$dir"* would silently skip them — leaving .env.age on the OLD key
+ # after rotation, i.e. undecryptable. Guard the full round-trip.
+ init_with_remote
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+
+ run "$SECRETS_BIN" rekey
+ [ "$status" -eq 0 ]
+
+ rm .env .env.staging
+ run "$SECRETS_BIN" pull
+ [ "$status" -eq 0 ]
+ [ "$(cat .env)" = "SECRET_KEY=abc123" ]
+ [ "$(cat .env.staging)" = "DB_HOST=staging.db.example.com" ]
+}
+
+@test "push reinstalls missing pre-commit hook" {
+ init_with_remote
+ rm "$SECRETS_DIR/.git/hooks/pre-commit"
+ create_project_dir
+
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Reinstalled pre-commit hook"* ]]
+ [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
+}
+
+@test "rekey reinstalls missing pre-commit hook" {
+ init_with_remote
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+ rm "$SECRETS_DIR/.git/hooks/pre-commit"
+
+ run "$SECRETS_BIN" rekey
+ [ "$status" -eq 0 ]
+ [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
+}
+
+@test "store protections heal is a silent no-op when nothing is missing" {
+ init_with_remote
+ create_project_dir
+
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"Restored store .gitignore"* ]]
+ [[ "$output" != *"Reinstalled pre-commit hook"* ]]
+}
+
+@test "restored store .gitignore carries the full block/allow globs" {
+ init_with_remote
+ rm "$SECRETS_DIR/.gitignore"
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+
+ grep -q "^key.txt$" "$SECRETS_DIR/.gitignore"
+ grep -qF '**/.env' "$SECRETS_DIR/.gitignore"
+ grep -qF '**/.dev.vars' "$SECRETS_DIR/.gitignore"
+ grep -qF '!**/.env.age' "$SECRETS_DIR/.gitignore"
+ grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore"
+ grep -qF '!**/.dev.vars.age' "$SECRETS_DIR/.gitignore"
+}
+
+@test "push heals .gitignore removed by remote history before staging (key never pushed)" {
+ init_with_remote
+ create_project_dir
+ "$SECRETS_BIN" push >/dev/null 2>&1
+ # Remote history drops .gitignore (e.g. an old machine committed without it)
+ git clone -q "$REMOTE_DIR" "$TEST_TMPDIR/other"
+ git -C "$TEST_TMPDIR/other" rm -q .gitignore
+ git -C "$TEST_TMPDIR/other" -c user.email=t@t -c user.name=t commit -qm "drop gitignore"
+ git -C "$TEST_TMPDIR/other" push -q
+
+ echo "B=2" >> .env
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ [ -f "$SECRETS_DIR/.gitignore" ]
+ run git -C "$SECRETS_DIR" ls-files
+ [[ "$output" != *"key.txt"* ]]
+}
+
+@test "push untracks a previously committed key.txt with a warning" {
+ init_with_remote
+ # Simulate legacy damage: key.txt got committed in the past
+ git -C "$SECRETS_DIR" add -f key.txt
+ git -C "$SECRETS_DIR" -c user.email=t@t -c user.name=t commit -qm "oops"
+ create_project_dir
+
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"key.txt was tracked"* ]]
+ run git -C "$SECRETS_DIR" ls-files
+ [[ "$output" != *"key.txt"* ]]
+}
+
+@test "push rewrites a store .gitignore that is missing the key.txt line" {
+ init_with_remote
+ printf '%s\n' '**/.env' > "$SECRETS_DIR/.gitignore"
+ create_project_dir
+
+ run "$SECRETS_BIN" push
+ [ "$status" -eq 0 ]
+ grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"
+ run git -C "$SECRETS_DIR" ls-files
+ [[ "$output" != *"key.txt"* ]]
+}
+
+@test "init guard renders the real clone URL when .secrets-store carries a remote" {
+ mkdir -p "$HOME/.secrets-work"
+ age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
+ [ -s "$HOME/.secrets-work/key.txt" ]
+ cd "$WORK_DIR"
+ echo "work git@example.com:me/secrets-work.git" > .secrets-store
+
+ run "$SECRETS_BIN" init
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]]
+}