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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ version in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json);

## [0.1.0] - 2026-10-15

- `/switch setup [name]` (#234) runs first-time setup from inside Claude: it shows the profile and settings diff (`setup --dry-run`, new), asks in chat, then writes with `--yes` and says to open a new terminal. `name` names the config dir you're on (`$CLAUDE_CONFIG_DIR`, else `~/.claude`; `main` by default) and is refused when that dir or name is already registered differently. Falls back to `python` when `python3` is the Windows Store stub. The README's first-time steps lead with it; the shell command stays as the fallback.
- `/switch list` (#230) shows per account whether it is logged in (`.credentials.json`, or on macOS an `oauthAccount` in `.claude.json`; never the Keychain), its email, and the last seen session and weekly usage with time left (`-` without a cache, `0%` past a reset). The hook answers `/switch list` itself, launcher or not, so it makes no API call and works at 100%.
- Workflows `unattended-backlog`, `climb-a-score` and `safe-to-close` gain `/switch` and `/remind` steps for usage limits (#235).
- Skills split into categories (#227): Plan and decide, Build and orchestrate, Sessions and accounts, Review and report, Repo and safety, Kanban board, plus agents and hooks. One list in `site/data/meta.json` (`categories`, was `map_groups`) drives the site home, `/map`, each page's tag and the README "What's here" tables; a test fails if they drift.
- skill pages: more spacing, bullets
Expand Down
2 changes: 1 addition & 1 deletion find-me-a-skill/CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Install section.
| session-bridge | skill | sonnet | low | `/session-bridge` | Coordinate with another Claude Code session or agent on the same repo — snapshot its state, check for overlap, send it a gated message. | two-sessions |
| sitrep | skill | haiku | low | `/sitrep` | Live status of this session in ≤10 lines — what's running, git state, PRs + CI, asked vs done, what needs the user, and a SAFE/NOT SAFE close verdict. | safe-to-close, two-sessions, usage-limit |
| spend-gate | skill | sonnet | medium | `/spend-gate` | Gate any billable action (GPU pods/instances, paid APIs or tiers, cloud credits) behind a cost estimate, a live balance check, and an explicit yes for that action. | paid-tests, climb-a-score |
| switch | skill (user only) | haiku | low | `/switch` | Move this conversation to another Claude account (the previous one, or one by name), or add, remove, rename and list accounts. | unattended-backlog, climb-a-score, usage-limit |
| switch | skill (user only) | haiku | low | `/switch` | Move this conversation to another Claude account (the previous one, or one by name), or set up, add, remove, rename and list accounts. | unattended-backlog, climb-a-score, usage-limit |
| teammate-brief | skill | sonnet | medium | `/teammate-brief` | Write an onboarding document for a person joining or picking up a project — what it is, setup steps verified by running them, env var names and where to get them, tasks with "done when", owners, don't-touch list. | brief-a-teammate |
| wayfinder | skill (user only) | opus | high | `/wayfinder` | Plan an effort too big for one session as a map of decision tickets (GitHub issue + sub-issues) and resolve them one per session until the way is clear, then hand off to divide. | big-effort |
| consult-plan | skill | opus | high | `/consult-plan` | Grill a proposed change, deviation, or new idea against a repo's existing plan (issues, board, roadmap, decisions), then record the outcome. | pitch-an-idea |
Expand Down
54 changes: 54 additions & 0 deletions scripts/test_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,60 @@ def test_no_yes_writes_nothing(self):
self.assertFalse(any(v.exists() for v in self.profiles().values()))
self.assertFalse(cs.registry_path().exists())

def tree(self):
return {str(p): p.read_bytes() if p.is_file() else None for p in self.tmp.rglob("*")}

def test_dry_run_writes_nothing(self):
before = self.tree()
rc, out = self.main("setup", "work", "--dry-run", *["--profile=%s=%s" % kv for kv in self.profiles().items()])
self.assertEqual(rc, 0)
self.assertIn("+claude-work() {", out)
self.assertTrue(out.rstrip().endswith("dry run: nothing changed"), out)
self.assertEqual(self.tree(), before)
self.assertFalse(self.sw.exists())
self.setup() # written; a rerun dry run is up to date and still writes nothing
before = self.tree()
rc, out = self.main("setup", "main", "--dry-run", *["--profile=%s=%s" % kv for kv in self.profiles().items()])
self.assertEqual((rc, out.strip()), (0, "claude-switch: everything is up to date"))
rc, out = self.main("setup", "--uninstall", "--dry-run")
self.assertEqual(rc, 0)
self.assertIn('- "statusLine"', out)
self.assertTrue(out.rstrip().endswith("dry run: nothing changed"), out)
self.assertEqual(self.tree(), before)

def test_name_registers_current_dir(self):
cfg = self.tmp / "cfg-work"
argv = ["--profile=%s=%s" % kv for kv in self.profiles().items()] + ["--yes"]
rc, out = self.main("setup", "work", *argv, env={"CLAUDE_CONFIG_DIR": str(cfg)})
self.assertEqual(rc, 0, out)
self.assertEqual([(a["name"], a["dir"]) for a in cs.load()["accounts"]], [("work", str(cfg.absolute()))])
rc, out = self.main("setup", "home", *argv) # CLAUDE_CONFIG_DIR unset: ~/.claude
self.assertEqual(rc, 0, out)
self.assertEqual(cs.find(cs.load(), "home")["dir"], str((self.home / ".claude").absolute()))
self.assertIn("claude-home()", self.profiles()["bash"].read_text())
rc, out = self.main("setup", "work", *argv, env={"CLAUDE_CONFIG_DIR": str(cfg)})
self.assertEqual((rc, out.splitlines()[-1]), (0, "claude-switch: everything is up to date"))

def test_first_run_main_is_current_dir(self):
cfg = self.tmp / "cfg-x"
rc, out = self.main("setup", "--yes", "--profile=bash=%s" % self.profiles()["bash"],
env={"CLAUDE_CONFIG_DIR": str(cfg)})
self.assertEqual(rc, 0, out)
self.assertEqual([(a["name"], a["dir"]) for a in cs.load()["accounts"]], [("main", str(cfg.absolute()))])

def test_name_refusals_write_nothing(self):
self.setup() # main = ~/.claude
before = self.tree()
cases = [(("setup", "work"), {}, "is already account 'main'; to rename it: /switch rename main work"),
(("setup", "main"), {"CLAUDE_CONFIG_DIR": str(self.tmp / "cfg-y")}, "account 'main' is already"),
(("setup", "Bad Name"), {}, "bad account name"),
(("setup", "work", "--uninstall"), {}, "takes no account name")]
for argv, env, want in cases:
with self.assertRaises(SystemExit, msg=argv) as cm:
self.main(*argv, "--yes", env=env)
self.assertIn(want, str(cm.exception))
self.assertEqual(self.tree(), before)

@unittest.skipUnless(shutil.which("bash") and os.name != "nt", "posix bash")
def test_bash_block_runs_fake(self):
self.setup("alt=%s" % (self.tmp / "alt"))
Expand Down
125 changes: 123 additions & 2 deletions scripts/test_switch_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,33 @@ def test_unknown_blocks_without_request(self):
self.assertEqual(self.requests(), [])

def test_passes_through(self):
for prompt in ("/switch add work2", "/switch remove alt", "/switch list", "/switch theme alt dark",
"/switcher alt", "please /switch alt", "hello"):
for prompt in ("/switch add work2", "/switch remove alt", "/switch list extra", "/switch theme alt dark",
"/switch setup", "/switch setup work", "/switcher alt", "please /switch alt", "hello"):
self.assertIsNone(self.hook(prompt, self.env), prompt)
self.assertIsNone(self.hook("/switch alt")) # not under a launcher: the skill shows setup steps
self.assertIsNone(self.hook(None, self.env, raw=b"not json"))
self.assertIsNone(self.hook(None, self.env, raw=b"[]"))
self.assertEqual(self.requests(), [])

def test_list_answered_with_or_without_launcher(self):
(self.home / ".claude").mkdir()
(self.home / ".claude" / ".credentials.json").write_text("{}")
for env in (self.env, {}):
out = self.hook(" /switch list ", env)
self.assertEqual(out["decision"], "block")
self.assertTrue(out["hookSpecificOutput"]["suppressOriginalPrompt"])
lines = out["reason"].splitlines()
self.assertEqual(len(lines), 3)
self.assertIn("logged in", lines[0])
self.assertTrue(lines[0].endswith("(current)")) # main: CLAUDE_CONFIG_DIR unset = ~/.claude
self.assertNotIn("\x1b", out["reason"])
self.assertEqual(self.hook("/skills:switch list")["decision"], "block")
self.assertEqual(self.requests(), [])

def test_list_broken_registry_passes_through(self):
cs.registry_path().write_text("{broken")
self.assertIsNone(self.hook("/switch list", self.env))

def test_guard_message_on_prompt(self):
cs.atomic_write(cs.usage_path("main"), json.dumps(
{"seen": time.time(), "five_hour": {"pct": 93, "resets_at": time.time() + 3600}}))
Expand Down Expand Up @@ -617,5 +636,107 @@ def test_rename_current_mid_session(self):
self.assertIn("boss -> alt", err)


class ListTest(Base):
def setUp(self):
super().setUp()
self.reg = self.register("main", "alt", "work") # work's dir never created
self.main_dir, self.alt_dir = self.home / ".claude", self.tmp / "cfg-alt"
self.main_dir.mkdir()
self.alt_dir.mkdir()

def lines(self, env=None):
with mock.patch.dict(os.environ, env or {}):
return dict(zip(("main", "alt", "work"), cs.list_lines(cs.load(), NOW)))

def test_login_and_email(self):
(self.main_dir / ".credentials.json").write_text("{}")
# ~/.claude keeps its .claude.json in HOME; a stray one inside the dir is ignored
(self.home / ".claude.json").write_text(json.dumps({"oauthAccount": {"emailAddress": "ana@example.com"}}))
(self.main_dir / ".claude.json").write_text(json.dumps({"oauthAccount": {"emailAddress": "old@example.com"}}))
(self.alt_dir / ".claude.json").write_text(json.dumps({"oauthAccount": {"emailAddress": "sam@example.com"}}))
out = self.lines()
self.assertIn(" logged in ana@example.com ", out["main"])
self.assertIn(" logged out sam@example.com ", out["alt"]) # no credentials file, not macOS
self.assertIn(" logged out - ", out["work"])
with mock.patch.object(cs, "MACOS", True): # login in the Keychain: oauthAccount counts
out = self.lines()
self.assertIn(" logged in sam@example.com", out["alt"])
self.assertIn(" logged out - ", out["work"])

def test_default_dir_falls_back_to_inner_file(self):
(self.main_dir / ".claude.json").write_text(json.dumps({"oauthAccount": {"emailAddress": "ana@example.com"}}))
self.assertIn("ana@example.com", self.lines()["main"])

def test_bad_claude_json(self):
for text in ("{not json", "[]", '{"oauthAccount": "x"}', '{"oauthAccount": {"emailAddress": 7}}', "\xff"):
(self.alt_dir / ".claude.json").write_bytes(text.encode("latin-1"))
with mock.patch.object(cs, "MACOS", True):
self.assertIn(" logged %s - " % ("in " if "oauthAccount\": {" in text else "out"),
self.lines()["alt"], text)

def test_usage_cells(self):
cs.atomic_write(cs.usage_path("main"), json.dumps({
"seen": NOW, "five_hour": {"pct": 42.4, "resets_at": NOW + 8010},
"seven_day": {"pct": 18, "resets_at": NOW + 4 * 86400 + 6 * 3600 + 600}}))
cs.atomic_write(cs.usage_path("alt"), "garbage")
cs.atomic_write(cs.usage_path("work"), json.dumps({ # stale: past reset, and seen too long ago
"seen": NOW - 6 * 3600, "five_hour": {"pct": 99, "resets_at": None},
"seven_day": {"pct": 70, "resets_at": NOW - 1}}))
out = self.lines()
self.assertIn("session 42% (2h13m left) week 18% (4d6h left)", out["main"])
self.assertRegex(out["alt"], r"session -\s+week -\s+last used never")
self.assertRegex(out["work"], r"session 0%\s+week 0%\s+last used never")

def test_aligned_current_no_ansi(self):
cs.touch("alt")
out = self.lines({"CLAUDE_CONFIG_DIR": str(self.alt_dir)})
cols = {line.index("session") for line in out.values()} | {line.index("last used") for line in out.values()}
self.assertEqual(len(cols), 2)
self.assertTrue(out["alt"].endswith("%s (current)" % self.alt_dir))
self.assertTrue(out["main"].startswith("main cyan "))
self.assertIn(" UTC ", out["alt"])
self.assertNotIn("\x1b", "".join(out.values()))
with mock.patch.dict(os.environ, {"CLAUDE_SWITCH_ACCOUNT": "work"}):
self.assertTrue(cs.list_lines(cs.load(), NOW, tty=True)[2].startswith("\x1b[43m \x1b[0m work"))

def test_empty(self):
cs.save({"accounts": []})
self.assertEqual(cs.list_lines(cs.load(), NOW), ["no accounts; run /switch setup, or migrate"])


class SkillRoutingTest(unittest.TestCase):
"""SKILL.md: every command it tells Claude to run is pre-allowed, and
setup is two steps like remove."""
def setUp(self):
text = (SCRIPT.parents[1] / "SKILL.md").read_text(encoding="utf-8")
head, self.body = text.split("\n---\n", 1)
self.allowed = [line.strip()[len("- Bash("):-len(":*)")] for line in head.splitlines()
if line.strip().startswith("- Bash(")]
self.hint = next(line for line in head.splitlines() if line.startswith("argument-hint:"))

def test_every_command_allowed(self):
cmds = [line.strip() for line in self.body.splitlines() if "claude_switch.py" in line
and line.strip().startswith(('"${CLAUDE_SWITCH_PYTHON', 'python "'))]
self.assertGreaterEqual(len(cmds), 8)
for cmd in cmds:
sub = "setup" if cmd.startswith("python ") else None
words = cmd.split(" $ARGUMENTS")[0]
self.assertTrue(any(a.startswith(words) for a in self.allowed), cmd)
if sub:
self.assertIn(words + " setup", self.allowed)

def test_setup_route(self):
self.assertIn("setup [name]", self.hint)
sec = self.body.split("**`setup [name]`**", 1)[1].split("\n**", 1)[0]
self.assertIn("$ARGUMENTS --dry-run", sec)
self.assertIn("$ARGUMENTS --yes", sec)
self.assertIn('"Write these changes? (yes/no)"', sec)
self.assertIn("explicit yes", sec)
self.assertIn("open a new terminal", sec)
for py in ('"${CLAUDE_SWITCH_PYTHON:-python3}"', "python"):
self.assertIn('%s "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" setup' % py, self.allowed)
self.assertIn("setup", cs.SUBCOMMANDS)


if __name__ == "__main__":
unittest.main()
Loading
Loading