Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion RESTORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 3 additions & 12 deletions scripts/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<root>/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)"
Expand Down
16 changes: 7 additions & 9 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF

Expand Down
98 changes: 98 additions & 0 deletions scripts/test/v2-check-rename.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@ set -euo pipefail
# (b3) an untagged old env key fails — the tag, not the key family, is what exempts.
# (c) a line tagged with a same-line "legacy" comment passes (exit 0).
# (d) old names under work/ are ignored (exit 0).
# (e) installer writes only /yshifu and leaves a custom retired command untouched.
# (f) doctor requires /yshifu and reports a retired command instead of using it.
#
# Run: scripts/test/v2-check-rename.test.sh (exits non-zero if any assert fails)

test_dir="$(cd "$(dirname "$0")" && pwd -P)"
gate="$test_dir/../check-rename.sh"
installer="$test_dir/../install.sh"
doctor="$test_dir/../doctor.sh"
if [ ! -x "$gate" ]; then
echo "FAIL: gate not found or not executable at $gate" >&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"
Expand Down Expand Up @@ -61,6 +69,20 @@ assert_contains() {
;;
esac
}
assert_not_contains() {
# assert_not_contains <label> <needle> <haystack>
case "$3" in
*"$2"*)
failed=$((failed + 1))
echo "FAIL: $1"
echo " unexpected: [$2]"
;;
*)
passed=$((passed + 1))
echo "pass: $1"
;;
esac
}

# make_repo <name> — init a git repo with one clean tracked file; echo its path.
make_repo() {
Expand Down Expand Up @@ -152,12 +174,88 @@ test_work_dir_ignored() {
assert_contains "(d) work/-only repo reports clean" "check-rename: clean" "$out"
}

# --- (e) installer creates only /yshifu and preserves unrelated retired file ----
test_installer_retires_bridge() {
local fresh_home="$tmproot/install-fresh"
local custom_home="$tmproot/install-custom"
local link_home="$tmproot/install-link"
local fresh_yshifu="no" fresh_faber="no" custom_after custom_backup="no"
local custom_out link_out link_still="no"

mkdir -p "$fresh_home" "$custom_home/.claude/commands"
HOME="$fresh_home" "$installer" >/dev/null
[ -f "$fresh_home/.claude/commands/yshifu.md" ] && fresh_yshifu="yes"
[ -e "$fresh_home/.claude/commands/faber.md" ] && fresh_faber="yes"
assert_eq "(e) fresh install creates /yshifu" "yes" "$fresh_yshifu"
assert_eq "(e) fresh install does not create retired command" "no" "$fresh_faber"

printf '%s\n' "custom command owned by the operator" > \
"$custom_home/.claude/commands/faber.md"
custom_out="$(HOME="$custom_home" "$installer" 2>&1)"
custom_after="$(cat "$custom_home/.claude/commands/faber.md")"
[ -e "$custom_home/.claude/commands/faber.md.bak" ] && custom_backup="yes"
assert_eq "(e) install leaves a custom retired command untouched" \
"custom command owned by the operator" "$custom_after"
assert_eq "(e) install does not back up the custom retired command" \
"no" "$custom_backup"
assert_contains "(e) install warns about the retired command" \
"WARNING: retired legacy /faber command remains" "$custom_out"

mkdir -p "$link_home/.claude/commands"
ln -s "$link_home/missing-target" "$link_home/.claude/commands/faber.md"
link_out="$(HOME="$link_home" "$installer" 2>&1)"
[ -L "$link_home/.claude/commands/faber.md" ] && link_still="yes"
assert_eq "(e) install leaves a dangling retired symlink untouched" "yes" "$link_still"
assert_contains "(e) install warns about a dangling retired symlink" \
"WARNING: retired legacy /faber command remains" "$link_out"
}

# --- (f) doctor has no retired fallback and reports the residual -----------------
test_doctor_rejects_retired_bridge() {
local doctor_text has_fallback="no" doctor_home="$tmproot/doctor-home"
local doctor_link_home="$tmproot/doctor-link-home"
local doctor_out doctor_rc=0 doctor_link_out doctor_link_rc=0
doctor_text="$(cat "$doctor")"
# Match the retired literal assignment, not a variable expansion.
# shellcheck disable=SC2016
case "$doctor_text" in
*'yshifu_cmd="$faber_cmd"'*|*'legacy-named /faber bridge copy found'*)
has_fallback="yes"
;;
esac
assert_eq "(f) doctor has no retired command fallback" "no" "$has_fallback"
assert_contains "(f) doctor reports a retired command residual" \
"retired legacy /faber command still exists" "$doctor_text"

mkdir -p "$doctor_home/.claude/commands"
printf '%s\n' "retired bridge" > "$doctor_home/.claude/commands/faber.md"
doctor_out="$(HOME="$doctor_home" PATH="/usr/bin:/bin" /bin/bash "$doctor" 2>&1)" || \
doctor_rc=$?
assert_eq "(f) doctor exits nonzero while retired command remains" "1" "$doctor_rc"
assert_contains "(f) doctor runtime reports the exact residual" \
"retired legacy /faber command still exists" "$doctor_out"
assert_not_contains "(f) doctor never calls the retired command valid" \
"valid until it is retired" "$doctor_out"

mkdir -p "$doctor_link_home/.claude/commands"
ln -s "$doctor_link_home/missing-target" \
"$doctor_link_home/.claude/commands/faber.md"
doctor_link_out="$(HOME="$doctor_link_home" PATH="/usr/bin:/bin" \
/bin/bash "$doctor" 2>&1)" || doctor_link_rc=$?
assert_eq "(f) doctor exits nonzero for dangling retired symlink" \
"1" "$doctor_link_rc"
assert_contains "(f) doctor runtime reports dangling retired symlink" \
"retired legacy /faber command still exists" "$doctor_link_out"
}

test_clean_repo_passes
test_stray_name_fails
test_case_insensitive_persona
test_untagged_env_key_fails
test_tagged_line_passes
test_work_dir_ignored
test_installer_retires_bridge
test_doctor_rejects_retired_bridge

echo
echo "passed: $passed, failed: $failed"
Expand Down