diff --git a/QUICKSTART.md b/QUICKSTART.md index 70a866c..ff35e4c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -47,9 +47,9 @@ command, point it at a target repo, and watch one loop run. For the mental model ``` Writes `~/.claude/commands/yshifu.md` with a path derived from this clone (no - hardcoded location). It also writes a bridge copy under the legacy `/faber` name, - so the old command keeps working until that bridge is retired. - Idempotent — re-running is safe. The script prints the next steps. + hardcoded location). Idempotent — re-running is safe. The script prints the next + steps. If it warns that a retired command file still exists, the installer has + left that file untouched; use RESTORE.md's explicit backup/removal checklist. 3. **Make sure the target repo has CI that runs on PRs** (the hard merge gate). This is the one real precondition — but **you no longer have to wire it yourself**: if the repo has no diff --git a/RESTORE.md b/RESTORE.md index 2a51a3f..af3df28 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -53,7 +53,61 @@ human channel. `~/.claude/commands/yshifu.md` from [`templates/yshifu-command.md`](templates/yshifu-command.md), substituting this clone's own path for the placeholder — so the command never hardcodes a repo location. Idempotent: re-running is safe, and an existing differing `yshifu.md` is - backed up to `yshifu.md.bak` before overwriting. It also writes a bridge copy under the legacy `/faber` name (same content, same backup rule) until that bridge is retired. + backed up to `yshifu.md.bak` before overwriting. A retired legacy command file is not + recreated or deleted by the installer. If legacy `~/.claude/commands/faber.md` remains, + the installer warns and leaves it byte-for-byte untouched. Retire it in this order before + running doctor/full smoke: + 1. Verify the new command exists and names this clone: + `test -f ~/.claude/commands/yshifu.md && grep -qF "$(pwd -P)/" ~/.claude/commands/yshifu.md`. + 2. Inspect whether the legacy file is the generated bridge or contains custom work. Never + discard custom content. + 3. Move it outside the active command-discovery tree into a unique timestamped + directory; never overwrite the installer's fixed `.bak` or an earlier retirement: + + ```sh + set -eu + legacy_cmd="$HOME/.claude/commands/faber.md" # legacy operator cleanup + if [ ! -e "$legacy_cmd" ] && [ ! -L "$legacy_cmd" ]; then + echo "retired command is already absent: $legacy_cmd" >&2 + exit 1 + fi + claude_root="$(cd "$HOME/.claude" && pwd -P)" + retired_root="$HOME/.claude/retired-commands" + if [ -L "$retired_root" ] || { [ -e "$retired_root" ] && [ ! -d "$retired_root" ]; }; then + echo "refusing unsafe retired-command root: $retired_root" >&2 + exit 1 + fi + if [ ! -e "$retired_root" ]; then + mkdir -m 700 "$retired_root" + fi + if [ ! -O "$retired_root" ] || [ -n "$(find "$retired_root" -prune \( -perm -020 -o -perm -002 \) -print -quit)" ]; then + echo "retired-command root must be owned by this user and not group/other writable" >&2 + exit 1 + fi + retired_real="$(cd "$retired_root" && pwd -P)" + case "$retired_real" in + "$claude_root"/*) ;; + *) echo "retired-command root escaped $claude_root" >&2; exit 1 ;; + esac + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + retired_dir="$(mktemp -d "$retired_real/legacy-faber-$stamp.XXXXXX")" # legacy backup + retired="$retired_dir/command.md" + if [ -e "$retired" ] || [ -L "$retired" ]; then + echo "refusing to overwrite retirement destination: $retired" >&2 + exit 1 + fi + if ! mv "$legacy_cmd" "$retired"; then + echo "failed to move retired command; original path was not intentionally removed" >&2 + exit 1 + fi + printf 'retired=%s\n' "$retired" + ``` + 4. Run `scripts/doctor.sh`, restart Claude Code, and run the full `/yshifu` smoke below. + 5. Roll back only if the command path is still absent: + Set `retired` to the exact path printed above, then run + `legacy_cmd="$HOME/.claude/commands/faber.md"; test ! -e "$legacy_cmd" && test ! -L "$legacy_cmd" && mv "$retired" "$legacy_cmd"`. + If either path conflicts, stop and inspect it; never overwrite. The move preserves the + original bytes and mode. # legacy rollback Do **not** recreate this command by hand. 4. Give that session GitHub access (`gh` CLI or the GitHub connector) so yshifu can read state and open issues. diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 379043c..b64f805 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -191,22 +191,13 @@ report_warn() { # (a) /yshifu points at this clone ------------------------------------------------- yshifu_cmd="$HOME/.claude/commands/yshifu.md" -faber_cmd="$HOME/.claude/commands/faber.md" # legacy fallback: installed before the rename +legacy_faber_cmd="$HOME/.claude/commands/faber.md" # legacy command retired after the rename # Match a path BOUNDARY ("$repo_root/"), not a bare prefix: the generated command # embeds paths like "/manager/CLAUDE.md", so the trailing slash anchors the # match to a path component and stops a clone whose path is a prefix of another's # (e.g. /work/ystack vs an installed /work/ystack-old) from false-passing. -if [ ! -f "$yshifu_cmd" ] && [ -f "$faber_cmd" ]; then # legacy fallback - # A pre-rename install may sit here during the bridge — but only a FRESH - # bridge copy counts. A stale pre-rename render still declares the old - # persona and old paths, contradicting the renamed manager/CLAUDE.md it - # loads, so it fails with the one-command fix. # legacy fallback - if grep -qE 'FABRICA_|\.fabrica/' "$faber_cmd"; then # legacy fallback - report 1 "(a) stale legacy /faber command found (pre-rename render) — re-run scripts/install.sh (it writes fresh copies under both names)" - else - yshifu_cmd="$faber_cmd" # legacy fallback - report 0 "(a) legacy-named /faber bridge copy found (fresh content) — valid until it is retired" - fi +if [ -e "$legacy_faber_cmd" ] || [ -L "$legacy_faber_cmd" ]; then # legacy retired command must be removed explicitly + report 1 "(a) retired legacy /faber command still exists at $legacy_faber_cmd — preserve it if customized, then move or delete it; scripts/install.sh no longer recreates it" fi if [ ! -f "$yshifu_cmd" ]; then report 1 "(a) /yshifu command installed at $yshifu_cmd (missing — run scripts/install.sh)" diff --git a/scripts/install.sh b/scripts/install.sh index 1ab6235..3eb240e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -24,9 +24,7 @@ repo_root="$(cd "$(dirname "$script_path")/.." && pwd -P)" template="$repo_root/templates/yshifu-command.md" commands_dir="$HOME/.claude/commands" target="$commands_dir/yshifu.md" -# Bridge: the docs still say the legacy /faber until the docs PR lands, so install BOTH -# names with identical content. Ops 4 deletes the legacy copy. # legacy fallback -legacy_target="$commands_dir/faber.md" # legacy fallback +legacy_faber_target="$commands_dir/faber.md" # legacy retired command; never mutate here if [ ! -f "$template" ]; then echo "error: template not found: $template" >&2 @@ -59,13 +57,13 @@ else action="created" fi -# Bridge copy under the legacy name, so the documented /faber keeps working -# until the docs PR and Ops 4. Same content, same backup rule. # legacy fallback -if [ -f "$legacy_target" ] && [ "$rendered" != "$(cat "$legacy_target")" ]; then - cp "$legacy_target" "$legacy_target.bak" # legacy fallback +# Retirement is deliberately non-destructive. Warn about the exact old entrypoint, +# including a dangling symlink, but never read, back up, overwrite, or delete it. +if [ -e "$legacy_faber_target" ] || [ -L "$legacy_faber_target" ]; then # legacy residual + echo "WARNING: retired legacy /faber command remains at $legacy_faber_target" >&2 + echo " /yshifu was updated; inspect and move the retired file explicitly" >&2 + echo " using RESTORE.md's rollback-safe cleanup. This installer leaves it untouched." >&2 fi -printf '%s\n' "$rendered" >"$legacy_target" # legacy fallback -echo "Bridge copy (legacy /faber, removed at Ops 4): $legacy_target" cat <&2 exit 1 fi +if [ ! -x "$installer" ] || [ ! -x "$doctor" ]; then + echo "FAIL: installer or doctor not found/executable" >&2 + exit 1 +fi # Make each throwaway repo look like a real one to git, without touching global config. export GIT_AUTHOR_NAME="test" GIT_AUTHOR_EMAIL="test@example.com" @@ -61,6 +69,20 @@ assert_contains() { ;; esac } +assert_not_contains() { + # assert_not_contains