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
73 changes: 73 additions & 0 deletions bin/claude-team
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,34 @@ resolve_name() {
echo "$profile"
}

# The coordinator is a behavior layer, not a team member. It has its own marker
# block, its own on/prod/off command, and both roster paths skip it: see the
# 'coordinator*' cases in cmd_list and persona_roster. resolve_name did not, so
# 'claude-team use coordinator' resolved, installed the coordinator profile into
# the CLAUDE-TEAM block, and made 'status' report "Active team member: Claude
# Team CLI". With the coordinator also enabled, ~/.claude/CLAUDE.md then held two
# copies of the same instructions, one in each marker pair.
#
# Called by 'use' and 'launch', which treat a name as a persona, and NOT by
# 'show', which only prints a file. Reading the coordinator profile is a
# legitimate thing to want, and blocking it there would also break the test that
# proves resolve_name's character class still accepts a hyphenated name.
#
# Called AFTER resolve_name, so a name that merely starts with "coordinator" and
# has no profile still gets the "no profile found" message rather than this one.
assert_is_persona() {
case "$1" in
coordinator*)
die "'$1' is the coordinator, not a team member, so it has no persona to activate.
Enable it instead with:
claude-team coordinator on (casual)
claude-team coordinator prod (branch enforcement)
To read the profile:
claude-team show $1"
;;
esac
}

# Title parsing. Profile titles use the form "# Name — Role"; both parts
# split at the FIRST em dash so a role may itself contain one.
#
Expand Down Expand Up @@ -669,6 +697,7 @@ cmd_use() {
name=$(lowercase "$1")
local profile
profile=$(resolve_name "$name")
assert_is_persona "$name"

# 'use' pins the persona globally, so every future session reads what lands
# here. The greeting is dropped: pinned, it makes every new session open with
Expand Down Expand Up @@ -1172,6 +1201,7 @@ cmd_launch() {
name=$(lowercase "$1")
shift
profile=$(resolve_name "$name")
assert_is_persona "$name"

local model task="" branch="" dry_run=false
model=$(get_default_model "$name")
Expand Down Expand Up @@ -1516,6 +1546,46 @@ good surfaces and one stale one. Fix the error above, then rerun
'claude-team sync': it recopies all three and brings them back level."
}

# Name the personas that are installed but no longer in the repo. sync only ever
# copies, so deleting or renaming a persona leaves its three installed files
# behind, and nothing else ever looks at them again. The roster is what makes
# that visible: cmd_list and persona_roster read PROFILES_DIR, not the repo, so a
# persona removed from the clone keeps appearing in 'claude-team list' and its
# '/<name>' slash command keeps resolving, forever.
#
# Reported, not deleted, and that is deliberate. ~/.claude/commands and
# ~/.claude/agents also hold files this tool never wrote, from other tools and
# from the user, and sync has no marker that tells its own files apart from
# theirs. A prune here would be an 'rm' driven by a guess about ownership, in the
# user's global config, which is the one place this tool is careful everywhere
# else: CLAUDE.md is protected by markers, settings.json by a merge, a foreign
# pre-commit hook by an outright refusal. Naming the paths costs the user one
# command and cannot destroy anything.
warn_orphaned_personas() {
# The two destination directories are passed in rather than read from
# cmd_sync's locals. Bash scoping would make that work, because a called
# function sees its caller's locals, but it would tie this function to one
# call site invisibly and break under 'set -u' anywhere else.
local repo_profiles="$1" agents_dst="$2" commands_dst="$3" installed name
local orphans=()
for installed in "$PROFILES_DIR"/*.md; do
[[ -f "$installed" ]] || continue
name=$(basename "$installed" .md)
[[ -f "$repo_profiles/$name.md" ]] || orphans+=("$name")
done
# Guarded before any expansion of the array itself: '${orphans[@]}' on an empty
# array is an unbound variable under 'set -u' on Bash before 4.4, and the
# supported floor here is Bash 4. '${#orphans[@]}' is a count and is always safe.
(( ${#orphans[@]} > 0 )) || return 0
echo ""
echo "$(yellow "!") Installed but no longer in the repo: ${orphans[*]}"
echo "$(dim " sync only copies, so a removed or renamed persona leaves its files behind.")"
echo "$(dim " These still appear in 'claude-team list'. Nothing was deleted. To remove:")"
for name in "${orphans[@]}"; do
echo " $(dim "rm -f $PROFILES_DIR/$name.md $agents_dst/$name.md $commands_dst/$name.md")"
done
}

# Regenerate agents from profiles, then copy all three installed surfaces from
# the clone into ~/.claude. A persona exists as three self-contained files
# (profile, slash command, subagent), so editing one installed copy leaves the
Expand Down Expand Up @@ -1561,6 +1631,9 @@ Make sure you are running this from the claude-team-cli repo."
cp "$repo_dir/commands"/*.md "$commands_dst/" \
|| sync_die "Failed to copy slash commands into $commands_dst."
echo "$(green "✓") Slash commands synced to $(dim "$commands_dst")"
# After all three copies land, so the comparison sees the state the user is
# left with rather than the one they started from.
warn_orphaned_personas "$repo_dir/profiles" "$agents_dst" "$commands_dst"
say_session_scope
echo ""
echo "$(dim "Edit profiles in $repo_dir/profiles, then rerun: claude-team sync")"
Expand Down
90 changes: 90 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,40 @@ out=$(run_cmd show coordinator-prod)
assert_contains "hyphenated name still resolves" "Claude Team CLI" "$out"
echo ""

# The coordinator is a behavior layer, not a team member: it has its own marker
# block, its own on/prod/off command, and both roster paths skip it. resolve_name
# did not, so 'claude-team use coordinator' resolved and installed the
# coordinator profile into the CLAUDE-TEAM block. 'status' then reported "Active
# team member: Claude Team CLI", and with the coordinator also enabled the file
# held two copies of the same instructions, one in each marker pair.
echo "the coordinator is not a team member"
assert_exits_nonzero "use rejects the coordinator" "$CLI" use coordinator
assert_exits_nonzero "use rejects the prod coordinator" "$CLI" use coordinator-prod
assert_exits_nonzero "launch rejects the coordinator" "$CLI" launch coordinator --dry-run
out=$(run_cmd use coordinator 2>&1 || true)
assert_contains "the refusal points at the command that does work" "claude-team coordinator on" "$out"

# Its own HOME, so the assertion holds wherever this section sits in the file.
# Against the shared one it would depend on nothing earlier having installed a
# block, which is true today and is not a property this test should rely on.
COORD_HOME=$(mktemp -d)
mkdir -p "$COORD_HOME/.claude"
CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$COORD_HOME" "$CLI" use coordinator >/dev/null 2>&1 || true
assert_file_lacks "a rejected coordinator writes no persona block" \
"$COORD_HOME/.claude/CLAUDE.md" "CLAUDE-TEAM:START"
rm -rf "$COORD_HOME"

# Guard the guard, in the other direction. The check belongs on 'use' and
# 'launch', which activate a name as a persona, and NOT in resolve_name, which
# 'show' also goes through: printing the coordinator profile is a legitimate
# thing to want, and coordinator-prod is the fixture the hyphenated-name test
# above uses, so blocking it there would break that test for an unrelated reason.
out=$(run_cmd show coordinator)
assert_contains "show still reads the coordinator profile" "Active Coordination" "$out"
out=$(run_cmd show coordinator-prod)
assert_contains "show still reads the prod coordinator profile" "Claude Team CLI" "$out"
echo ""

# use — basic injection
echo "use"
run_cmd use robin >/dev/null
Expand Down Expand Up @@ -1549,6 +1583,62 @@ rm -rf "$INSTALL_HOME" "$INSTALL_REPO"

echo ""

# sync only ever copies. Deleting or renaming a persona in the clone therefore
# leaves its three installed files behind, and the roster is read from the
# installed profiles rather than from the repo, so the removed persona keeps
# appearing in 'claude-team list' and its '/<name>' command keeps resolving.
#
# Runs from a throwaway copy of the repo, never REPO_DIR, for the reason given
# in the install.sh block above: sync resolves its repo directory from the CLI's
# own path and regenerates agents/ and commands/ there, so running it against the
# real clone would rewrite this repo's own tracked files.
echo "sync reports personas left behind by a delete or rename"

SYNC_REPO=$(mktemp -d)
cp -R "$REPO_DIR"/profiles "$REPO_DIR"/commands "$REPO_DIR"/agents \
"$REPO_DIR"/scripts "$REPO_DIR"/bin "$SYNC_REPO/"
SYNC_HOME=$(mktemp -d)
SYNC_CLI="$SYNC_REPO/bin/claude-team"
# HOME only, with no CLAUDE_TEAM_PROFILES: the point of this test is the default
# installed location, which is what sync compares the repo against.
out=$(HOME="$SYNC_HOME" "$SYNC_CLI" sync 2>&1)
assert_not_contains "a clean sync reports nothing left behind" "no longer in the repo" "$out"

# Guard the guard: if this persona is ever renamed, the removal below becomes a
# no-op and every assertion after it would pass for the wrong reason.
if [[ -f "$SYNC_REPO/profiles/piper.md" ]]; then
ok "the persona to remove exists, so these tests are not vacuous"
else
fail "the persona to remove exists, so these tests are not vacuous (piper renamed: repoint this test)"
fi
rm -f "$SYNC_REPO/profiles/piper.md" "$SYNC_REPO/agents/piper.md" "$SYNC_REPO/commands/piper.md"
out=$(HOME="$SYNC_HOME" "$SYNC_CLI" sync 2>&1)
assert_contains "sync names the persona left behind" "piper" "$out"
assert_contains "sync says why it is still installed" "no longer in the repo" "$out"
assert_contains "sync prints the command that clears it" "rm -f" "$out"

# Reported, not deleted. sync shares ~/.claude/commands and ~/.claude/agents with
# files it never wrote, and has no marker separating its own from a user's, so it
# must not rm on a guess about ownership.
if [[ -f "$SYNC_HOME/.claude/team/piper.md" ]]; then ok "sync deletes no leftover profile"
else fail "sync deletes no leftover profile (IT WAS DELETED)"; fi
if [[ -f "$SYNC_HOME/.claude/commands/piper.md" ]]; then ok "sync deletes no leftover slash command"
else fail "sync deletes no leftover slash command (IT WAS DELETED)"; fi
if [[ -f "$SYNC_HOME/.claude/agents/piper.md" ]]; then ok "sync deletes no leftover subagent"
else fail "sync deletes no leftover subagent (IT WAS DELETED)"; fi

# A persona still in the repo must not be reported, or the warning is noise.
# Scoped to the report line, not the whole run: say_session_scope prints
# "slash commands (/akira, /robin, ...)" on every sync, so asserting against all
# of $out would fail on text that has nothing to do with this check.
orphan_report=$(grep "no longer in the repo" <<< "$out" || true)
assert_not_contains "the report names only the removed persona" "robin" "$orphan_report"
# The leftover showing in the roster is the symptom the report exists to explain.
out=$(CLAUDE_TEAM_PROFILES="$SYNC_HOME/.claude/team" HOME="$SYNC_HOME" "$SYNC_CLI" list 2>&1)
assert_contains "the leftover persona still shows in list, which is the symptom" "Piper" "$out"
rm -rf "$SYNC_REPO" "$SYNC_HOME"
echo ""

# ─── Documentation drift ──────────────────────────────────────────────────────
#
# Five sessions in a row ran an open-ended "review the docs for drift" prompt
Expand Down
Loading