From b0fee58e24ed11b94ffa4a9aad368516540fa00b Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sat, 17 Oct 2026 02:40:34 -0400 Subject: [PATCH 1/5] feat(switch): list shows login, email, cached usage; hook answers /switch list (#230) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WHwM2TcQBo9oHdCrYCtdMn --- scripts/test_switch_usage.py | 91 ++++++++++++++++++++- switch/scripts/claude_switch.py | 138 +++++++++++++++++++++++--------- switch/scripts/statusline.py | 11 +-- 3 files changed, 190 insertions(+), 50 deletions(-) diff --git a/scripts/test_switch_usage.py b/scripts/test_switch_usage.py index 904e69c..43621ed 100644 --- a/scripts/test_switch_usage.py +++ b/scripts/test_switch_usage.py @@ -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}})) @@ -617,5 +636,73 @@ 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"]) + + if __name__ == "__main__": unittest.main() diff --git a/switch/scripts/claude_switch.py b/switch/scripts/claude_switch.py index 3618e84..412ba11 100644 --- a/switch/scripts/claude_switch.py +++ b/switch/scripts/claude_switch.py @@ -36,6 +36,7 @@ from pathlib import Path WINDOWS = os.name == "nt" +MACOS = sys.platform == "darwin" NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") COLOURS = ["cyan", "magenta", "yellow", "green", "blue", "red"] @@ -1119,21 +1120,60 @@ def cmd_rename(args): return 0 -def cmd_list(args): - reg = load() +def login_info(d): + """(logged in, email) from the account's own files; never the Keychain. + Logged in = .credentials.json in its dir; on macOS (login in the + Keychain) an oauthAccount in its .claude.json. ~/.claude keeps that + file at ~/.claude.json, because the launcher unsets CLAUDE_CONFIG_DIR + for it.""" + d = Path(d) + paths = ([Path.home() / ".claude.json"] if is_default_dir(d) else []) + [d / ".claude.json"] + cfg = next((c for c in map(_read_json, paths) if c), {}) + oauth = cfg.get("oauthAccount") if isinstance(cfg.get("oauthAccount"), dict) else None + email = (oauth or {}).get("emailAddress") + email = email.strip() if isinstance(email, str) and email.strip() else "-" + return (d / ".credentials.json").is_file() or (MACOS and oauth is not None), email + + +def usage_cells(name, now): + """['session 42% (2h13m left)', 'week -'] from the usage cache the + statusline writes; a window past its reset is 0%, as the guard counts it.""" + data = _read_json(usage_path(name)) + out = [] + for (key, span), label in zip(WINDOWS_USAGE, ("session", "week")): + pct, r = window_now(data, key, span, now) + cell = "%s -" % label if pct is None else "%s %d%%" % (label, round(pct)) + if pct is not None and r is not None: + cell += " (%s left)" % time_left(r - now) + out.append(cell) + return out + + +def list_lines(reg, now, tty=False): + """One aligned line per account: name, colour, login, email, session + and week usage, last used, dir, (current). ANSI only with tty.""" if not reg["accounts"]: - print("no accounts; run setup or migrate") - return 0 - current = os.environ.get("CLAUDE_SWITCH_ACCOUNT") - tty = sys.stdout.isatty() - width = max(len(a["name"]) for a in reg["accounts"]) + return ["no accounts; run /switch setup, or migrate"] + current = current_account(reg) + rows = [] for a in reg["accounts"]: - colour = a.get("colour") or "-" - swatch = "\x1b[%dm \x1b[0m " % (ANSI[colour] + 10) if tty and colour in ANSI else "" + logged_in, email = login_info(a["dir"]) used = (a.get("last_used") or "")[:16].replace("T", " ") - print("%s%-*s %-7s %s last used %s%s" % (swatch, width, a["name"], colour, a["dir"], - used + " UTC" if used else "never", - " (current)" if a["name"] == current else "")) + rows.append([a["name"], a.get("colour") or "-", "logged in" if logged_in else "logged out", email] + + usage_cells(a["name"], now) + ["last used " + (used + " UTC" if used else "never"), a["dir"]]) + widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]) - 1)] + out = [] + for a, row in zip(reg["accounts"], rows): + colour = a.get("colour") + swatch = "\x1b[%dm \x1b[0m " % (ANSI[colour] + 10) if tty and colour in ANSI else "" + line = " ".join([c.ljust(w) for c, w in zip(row, widths)] + [row[-1]]) + out.append(swatch + line + (" (current)" if a is current else "")) + return out + + +def cmd_list(args): + for line in list_lines(load(), time.time(), tty=sys.stdout.isatty()): + print(line) return 0 @@ -1411,7 +1451,7 @@ def write_settings(path, old, new): WINDOWS_USAGE = (("five_hour", 5 * 3600), ("seven_day", 7 * 86400)) LEVELS = (90, 97) SWITCH_RE = re.compile(r"^/(?:[\w.-]+:)?switch(?:\s+(.*))?$", re.S) -SUBCOMMANDS = ("add", "remove", "rename", "list", "theme") +SUBCOMMANDS = ("setup", "add", "remove", "rename", "list", "theme") def usage_path(name, suffix=""): @@ -1447,25 +1487,44 @@ def record_usage(name, rate_limits, now): atomic_write(usage_path(name), json.dumps(data) + "\n") +def window_now(data, key, span, now): + """(pct, resets_at) of one cached window: (None, None) when never seen; + 0% once past its reset or seen longer ago than the window lasts.""" + w = data.get(key) + if not isinstance(w, dict) or not _num(w.get("pct")): + return None, None + r, seen = w.get("resets_at"), data.get("seen") + if _num(r): + if r <= now: + return 0.0, None + elif not _num(seen) or now - seen > span: + return 0.0, None + return float(w["pct"]), (r if _num(r) else None) + + def effective_usage(data, now): """(pct, window, resets_at) of the fullest window still current. A window past its reset, or seen longer ago than it lasts, counts as 0.""" best = (0.0, None, None) for key, span in WINDOWS_USAGE: - w = data.get(key) - if not isinstance(w, dict) or not _num(w.get("pct")): - continue - r, seen = w.get("resets_at"), data.get("seen") - if _num(r): - if r <= now: - continue - elif not _num(seen) or now - seen > span: - continue - if w["pct"] > best[0]: - best = (float(w["pct"]), key, r) + pct, r = window_now(data, key, span, now) + if pct and pct > best[0]: + best = (pct, key, r) return best +def time_left(secs): + secs = int(secs) + if secs < 60: + return "<1m" + d, h, m = secs // 86400, secs % 86400 // 3600, secs % 3600 // 60 + if d: + return "%dd%dh" % (d, h) + if h: + return "%dh%02dm" % (h, m) + return "%dm" % m + + def guard(reg, now): """Warning text the first time the current account crosses 90% and again at 97% in one window, or None. Only with 2+ accounts.""" @@ -1497,25 +1556,28 @@ def guard(reg, now): def cmd_hook(): """UserPromptSubmit. `/switch` or `/switch ` under a launcher: write the request and block the prompt, so nothing reaches the API (works at - 100% usage). Otherwise the usage guard. Never fails the prompt.""" + 100% usage). `/switch list` is answered here too, launcher or not. + Otherwise the usage guard. Never fails the prompt.""" try: # utf-8-sig: PowerShell 5.1 piping into the command can prepend a BOM data = json.loads(sys.stdin.buffer.read().decode("utf-8-sig", "replace").strip() or "{}") if not isinstance(data, dict): return 0 m = SWITCH_RE.match(str(data.get("prompt") or "").strip()) - launcher = os.environ.get("CLAUDE_SWITCH_LAUNCHER") - if m and launcher: - words = (m.group(1) or "").split() - if not words or words[0] not in SUBCOMMANDS: # subcommands go through the skill - name, msg = request_name(load(), words) - if not msg: - _, msg = make_request(name, str(data.get("session_id") or ""), - transcript=data.get("transcript_path") or None, cwd=data.get("cwd") or None) - print(json.dumps({"decision": "block", "reason": msg, - "hookSpecificOutput": {"hookEventName": HOOK_EVENT, - "suppressOriginalPrompt": True}})) - return 0 + words = (m.group(1) or "").split() if m else [] + msg = None + if m and words == ["list"]: # read-only, needs no launcher: answered here, so it works at 100% too + msg = "\n".join(list_lines(load(), time.time())) + elif m and os.environ.get("CLAUDE_SWITCH_LAUNCHER") and (not words or words[0] not in SUBCOMMANDS): + name, msg = request_name(load(), words) # other subcommands go through the skill + if not msg: + _, msg = make_request(name, str(data.get("session_id") or ""), + transcript=data.get("transcript_path") or None, cwd=data.get("cwd") or None) + if msg: + print(json.dumps({"decision": "block", "reason": msg, + "hookSpecificOutput": {"hookEventName": HOOK_EVENT, + "suppressOriginalPrompt": True}})) + return 0 try: warn = guard(load(), time.time()) except Exception as e: @@ -1595,7 +1657,7 @@ def main(argv=None): rn = sub.add_parser("rename", help="rename an account (its dir is not moved)") rn.add_argument("old") rn.add_argument("new") - sub.add_parser("list", help="accounts, colour, dir, last used") + sub.add_parser("list", help="accounts: colour, login, email, cached usage, last used, dir") sub.add_parser("migrate", help="register ~/.claude and ~/.claude-* dirs; links nothing") sub.add_parser("hook", help="UserPromptSubmit hook (stdin JSON): /switch without the API, usage guard") th = sub.add_parser("theme", help="show or set an account's Claude theme ('-' clears)") diff --git a/switch/scripts/statusline.py b/switch/scripts/statusline.py index 99426c1..4725d5c 100644 --- a/switch/scripts/statusline.py +++ b/switch/scripts/statusline.py @@ -25,16 +25,7 @@ LABELS = (("five_hour", "session"), ("seven_day", "week")) -def time_left(secs): - secs = int(secs) - if secs < 60: - return "<1m" - d, h, m = secs // 86400, secs % 86400 // 3600, secs % 3600 // 60 - if d: - return "%dd%dh" % (d, h) - if h: - return "%dh%02dm" % (h, m) - return "%dm" % m +time_left = cs.time_left # one format for the statusline and `list` def window(label, w, now): From 6365239daa5304318080a363e37451554123b381 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sat, 17 Oct 2026 02:40:39 -0400 Subject: [PATCH 2/5] feat(switch): /switch setup [name] from inside Claude, setup --dry-run (#234) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WHwM2TcQBo9oHdCrYCtdMn --- scripts/test_switch.py | 54 ++++++++++++++++++++++++ scripts/test_switch_usage.py | 34 +++++++++++++++ switch/SKILL.md | 31 ++++++++++++-- switch/scripts/claude_switch.py | 74 ++++++++++++++++++++++++++------- 4 files changed, 176 insertions(+), 17 deletions(-) diff --git a/scripts/test_switch.py b/scripts/test_switch.py index 622e633..9ba4461 100644 --- a/scripts/test_switch.py +++ b/scripts/test_switch.py @@ -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")) diff --git a/scripts/test_switch_usage.py b/scripts/test_switch_usage.py index 43621ed..1bba62d 100644 --- a/scripts/test_switch_usage.py +++ b/scripts/test_switch_usage.py @@ -704,5 +704,39 @@ def test_empty(self): 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() diff --git a/switch/SKILL.md b/switch/SKILL.md index 35446da..32c2a15 100644 --- a/switch/SKILL.md +++ b/switch/SKILL.md @@ -1,10 +1,12 @@ --- name: switch -description: Move this conversation to another Claude account (the previous one, or one by name), or add, remove, rename and list accounts. -argument-hint: "[account name] | add | remove [--delete-dir] | rename | list | theme [theme]" +description: Move this conversation to another Claude account (the previous one, or one by name), or set up, add, remove, rename and list accounts. +argument-hint: "[account name] | setup [name] | add | remove [--delete-dir] | rename | list | theme [theme]" disable-model-invocation: true allowed-tools: - Bash("${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" request:*) + - Bash("${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" setup:*) + - Bash(python "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" setup:*) - Bash("${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" add:*) - Bash("${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" remove:*) - Bash("${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" rename:*) @@ -14,6 +16,29 @@ allowed-tools: Pick the branch by the first word of `$ARGUMENTS` and run only its commands with the Bash tool, exactly as written, no text before the first one. +**`setup [name]`** (first-time setup; `name` is the account you're on, default `main`): two steps, never one. + +1. Show the changes, then ask "Write these changes? (yes/no)" and stop until the user answers: + + ``` + "${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" $ARGUMENTS --dry-run + ``` + + If it says Python was not found or `python3: command not found` (on Windows `python3` is often a Microsoft Store stub), run this instead, and use `python` in step 2 too: + + ``` + python "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" $ARGUMENTS --dry-run + ``` + + `everything is up to date` or a refusal (a bad name, a name or dir already taken) ends it here: show the output as is. +2. Only on the user's explicit yes in their next message, run the same command with `--yes` in place of `--dry-run`: + + ``` + "${CLAUDE_SWITCH_PYTHON:-python3}" "${CLAUDE_SKILL_DIR}/scripts/claude_switch.py" $ARGUMENTS --yes + ``` + + Then tell the user: open a new terminal and start Claude with `claude` (or `claude-`); `/switch` works in sessions started that way. Anything but yes: say nothing was changed and stop. + **`add [--dir DIR]`**: ``` @@ -54,4 +79,4 @@ Show its output as is. ``` - Ends with `switching to ''` → the launcher ends this session and resumes it on that account. Add nothing. -- Anything else (unknown name with the account list, already on that account, not started by the launcher with setup steps, python not found) → nothing was switched; show the output as is. Python not found means the session wasn't started by the launcher: point at the setup step in this skill's README. +- Anything else (unknown name with the account list, already on that account, not started by the launcher with setup steps, python not found) → nothing was switched; show the output as is. Python not found means the session wasn't started by the launcher: suggest `/switch setup`. diff --git a/switch/scripts/claude_switch.py b/switch/scripts/claude_switch.py index 412ba11..fb91222 100644 --- a/switch/scripts/claude_switch.py +++ b/switch/scripts/claude_switch.py @@ -4,8 +4,8 @@ run [name|-] [claude args] launcher loop; '-' or no name = most recent request [name] --session ID pre-switch notes, then ask this session's launcher to switch - setup [--account NAME=DIR ...] [--profile SHELL=PATH ...] [--yes] - register accounts, write shell wrappers + setup [name] [--account NAME=DIR ...] [--profile SHELL=PATH ...] [--yes|--dry-run] + register accounts (name = this config dir), write shell wrappers add NAME [--dir DIR] new account sharing the first one's items remove NAME [--delete-dir] [--yes] [--dry-run] rename OLD NEW | list | migrate @@ -197,8 +197,8 @@ def setup_command(): def setup_steps(): return ( "this session was not started by the claude-switch launcher, so it can't switch.\n" - "set up once, then start Claude with `claude` or `claude-` from a new shell:\n " - + setup_command() + "set up once with /switch setup (or from a shell, below), then start Claude with `claude` " + "or `claude-` from a new terminal:\n " + setup_command() ) @@ -676,7 +676,7 @@ def read_text(p): """(text, had_bom); missing file = ('', False).""" try: raw = Path(p).read_bytes() - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): return "", False bom = raw.startswith(b"\xef\xbb\xbf") try: @@ -742,18 +742,45 @@ def install_copy(): _retry(lambda: os.replace(tmp, dst)) +def config_dir(): + """The config dir this process runs under: $CLAUDE_CONFIG_DIR, else ~/.claude.""" + return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).absolute() + + +def name_current_dir(reg, name): + """setup NAME: register the config dir this runs under as NAME. Refuses + to give that dir a second name or to move NAME off another dir.""" + if not NAME_RE.match(name): + raise SystemExit("claude-switch: bad account name %r (use a-z, 0-9, -, _)" % name) + d = config_dir() + holder = next((a for a in reg["accounts"] if _norm(a["dir"]) == _norm(d)), None) + if holder and holder["name"] != name: + raise SystemExit("claude-switch: %s is already account '%s'; to rename it: /switch rename %s %s" + % (d, holder["name"], holder["name"], name)) + a = find(reg, name) + if a and not holder: + raise SystemExit("claude-switch: account '%s' is already %s; pick another name" % (name, a["dir"])) + if not a: + upsert(reg, name, str(d)) + + def cmd_setup(args): - ensure_home() - reg = load() + reg = load() # everything up to the plan is in memory: a refusal or --dry-run writes nothing if args.uninstall: + if args.name: + raise SystemExit("claude-switch: setup --uninstall takes no account name") + if not args.dry_run: + ensure_home() return setup_uninstall(reg, args) + if args.name or not (reg["accounts"] or args.account): + name_current_dir(reg, args.name or "main") # first run: the account you're on is main for spec in args.account or []: name, sep, d = spec.partition("=") if not sep or not d: raise SystemExit("claude-switch: --account wants NAME=DIR, got %r" % spec) upsert(reg, name.strip(), d.strip()) - if not reg["accounts"]: - upsert(reg, "main", str(Path.home() / ".claude")) # D418 + if not args.dry_run: + ensure_home() if args.profile: targets = {} for spec in args.profile: @@ -771,9 +798,12 @@ def cmd_setup(args): if not Path(a["dir"]).is_dir(): print("note: %s does not exist yet; claude creates it on first login as '%s'" % (a["dir"], a["name"])) if not plan: - install_copy() + if not args.dry_run: + install_copy() print("claude-switch: everything is up to date") return 0 + if args.dry_run: + return dry_run(plan) if not confirm_plan(plan, args.yes): return 1 install_copy() @@ -784,16 +814,26 @@ def cmd_setup(args): return 0 +def dry_run(plan): + show_plan(plan) + print("dry run: nothing changed") + return 0 + + def confirm_plan(plan, yes): + show_plan(plan) + if yes or ask("Write these changes? [y/N] "): + return True + print("nothing written") + return False + + +def show_plan(plan): for path, old, new, _ in plan: for line in difflib.unified_diff( old.replace("\r\n", "\n").splitlines(True), (new or "").replace("\r\n", "\n").splitlines(True), str(path) if old else "/dev/null", str(path) if new is not None else "/dev/null"): sys.stdout.write(line if line.endswith("\n") else line + "\n") # last line without one: no gluing - if yes or ask("Write these changes? [y/N] "): - return True - print("nothing written") - return False def ask(prompt): @@ -830,6 +870,8 @@ def setup_uninstall(reg, args): if not plan: print("claude-switch: no statusline or hook of ours in %s" % files) return 0 + if args.dry_run: + return dry_run(plan) if not confirm_plan(plan, args.yes): return 1 write_plan(plan) @@ -1639,7 +1681,11 @@ def main(argv=None): r.add_argument("words", nargs="*", metavar="name", help="account (default: the previous one)") r.add_argument("--session", default="") s = sub.add_parser("setup", help="register accounts and write shell wrappers") + s.add_argument("name", nargs="?", + help="account name for the config dir you're on ($CLAUDE_CONFIG_DIR, else ~/.claude); " + "first run default: main") s.add_argument("--account", action="append", metavar="NAME=DIR") + s.add_argument("--dry-run", action="store_true", help="print the diff, write nothing") s.add_argument("--profile", action="append", metavar="SHELL=PATH", help="write only these (%s; cmd takes the shim dir)" % ", ".join(SHELLS)) s.add_argument("--yes", action="store_true", help="write without asking") From 6facfb72da5fa56cb847bd66b8e0d4dfe2281eb6 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sat, 17 Oct 2026 02:41:29 -0400 Subject: [PATCH 3/5] chore(catalog): switch description mentions setup (#234) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WHwM2TcQBo9oHdCrYCtdMn --- find-me-a-skill/CATALOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/find-me-a-skill/CATALOG.md b/find-me-a-skill/CATALOG.md index cba4372..87d2295 100644 --- a/find-me-a-skill/CATALOG.md +++ b/find-me-a-skill/CATALOG.md @@ -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. | 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. | 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 | From d288d6db4676ccf2fd41f1747d712bceecc80f29 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sat, 17 Oct 2026 02:41:29 -0400 Subject: [PATCH 4/5] docs(switch): first time leads with /switch setup; list fields; changelog (#230, #234) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WHwM2TcQBo9oHdCrYCtdMn --- CHANGELOG.md | 2 ++ switch/README.md | 46 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ce6a8..2033b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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%. - skill pages: more spacing, bullets - New `remind` skill (#226): `/remind when 's session|weekly limit resets`, `when is under N%`, `in 2h ...`, `at 3pm ...`, `list`, `cancel `. Reset times come from `switch`'s usage cache (unknown account, no cache or an already-reset window is an error, never a guess). Due reminders show once at the next prompt on any account through `switch`'s hook (a one-line hook without switch); timed ones also get a one-shot desktop notification (Windows scheduled task with a PowerShell toast, `msg` fallback; `at` on macOS/Linux). `reminders.json` sits next to the switch registry (`~/.claude-remind/` without it). - `switch` statusline and hook in every account (#219): `setup` and `setup --uninstall` write and remove them in each distinct account `settings.json` (a symlinked or hardlinked one once, a missing dir skipped, a non-JSON file warned about and left alone) after one diff and `[y/N]`; each file's own statusline is recorded and restored (the old one-file registry entry carries over). `add` and `migrate` do the same for a new account with its own `settings.json`, and `remove` takes them out of a file no other account uses, so `/switch` from any account goes through the hook without an API call. diff --git a/switch/README.md b/switch/README.md index 3683d5c..9f9af1a 100644 --- a/switch/README.md +++ b/switch/README.md @@ -8,7 +8,8 @@ Runs several Claude Code accounts side by side, each with its own config dir, an - Start Claude with `claude` (the account you used last) or `claude-`. - Inside a session, `/switch` closes it and reopens the same conversation on your previous account, `/switch ` on a named one. -- `/switch add|remove|rename|list` manages the accounts; new ones share skills, settings and history with the first. +- `/switch setup` does the one-time setup from inside Claude. +- `/switch add|remove|rename|list` manages the accounts; new ones share skills, settings and history with the first. `list` shows who is logged in where and how much usage each account has left. - Before each switch it prints what stays with the old account (artifacts, routines) and what ends or pauses (Remote Control, running subagents, background commands); it never asks. - A statusline shows which account you're on and how much of its session and weekly limits is used, and warns you near the limit with the account that has the most room left. @@ -26,9 +27,9 @@ Runs several Claude Code accounts side by side, each with its own config dir, an **First time**, start to finish: 1. Install the skills: `scripts/install-skills.sh` (on Windows, `scripts/install-skills.ps1`). -2. Name the account you already use: `python3 ~/.claude/skills/switch/scripts/claude_switch.py setup --account work=~/.claude` (on Windows, `python`). - - It shows the profile and settings diff and writes only after you type `y`. - - Without `--account` (and with no accounts registered yet) it registers `~/.claude` as `main`. Rename it later with `/switch rename main work`. +2. Start Claude as usual and type `/switch setup work` to name the account you're on `work` (plain `/switch setup` calls it `main`). + - Claude shows the shell profile and settings changes it would make and asks "Write these changes?". Nothing is written until you say yes. + - Rename the account later with `/switch rename main work`. 3. Open a new terminal, because shell profiles only load in new shells (or reload with `source ~/.bashrc` / `. $PROFILE`). Then run `claude` (or `claude-work`). 4. Inside that session, run `/switch add personal`. - It creates `~/.claude-personal`, links your first account's skills, settings and history into it, and reopens this conversation there. @@ -36,10 +37,15 @@ Runs several Claude Code accounts side by side, each with its own config dir, an - This switch-in only happens when Claude was started by the launcher (`claude` or `claude-`). Otherwise it only adds the account and tells you to start it with `claude-personal` from a new shell. 5. From then on, `/switch` flips back to the other account, `claude-personal` starts on it directly, and `/switch list` shows both. -To register two accounts in one go, run this from a shell (use the installed copy, e.g. -`~/.claude/skills/switch/scripts/claude_switch.py`). On Windows type -`python` (or `py`) wherever these examples say `python3`: there -`python3` is usually the Microsoft Store stub. +Claude not running, or you only use cmd? Run step 2 from a shell instead, with the installed copy of the script. +On Windows type `python` (or `py`) wherever these examples say `python3`: there `python3` is usually the Microsoft Store stub. + +``` +python3 ~/.claude/skills/switch/scripts/claude_switch.py setup work +``` + +It prints the same diff and writes only after you type `y`; `--dry-run` prints it and writes nothing. +To register two accounts in one go: ``` python3 claude_switch.py setup --account work=~/.claude --account personal=~/.claude-personal @@ -53,6 +59,7 @@ Then, in a new shell, run `claude` or `claude-personal`. Inside a session you ha ``` /switch [name] +/switch setup [name] /switch add [--dir DIR] /switch remove [--delete-dir] /switch rename @@ -67,7 +74,9 @@ statusline and hook back out of `settings.json`: ## How it works 1. **Setup** (`claude_switch.py setup`) registers each `--account NAME=DIR` in `~/.claude-switch/accounts.json` (name, dir, colour, theme, last-used; written atomically). It also copies the script there. The wrappers call that copy, so updating or uninstalling the skill doesn't break your shells. - - No `--account` on a first run registers `main` = `~/.claude`. + - `setup ` registers the config dir you're on (`$CLAUDE_CONFIG_DIR`, else `~/.claude`) as ``. It refuses a name or dir that is already registered differently. + - With neither, a first run registers the config dir you're on as `main`. + - `--dry-run` prints the diff and writes nothing. `/switch setup` runs that first, then `--yes` after you agree in chat. - It then writes a block between `# >>> claude-switch >>>` markers into each shell profile it finds (bash, zsh, fish, PowerShell 5.1 and 7) and `claude.cmd` / `claude-.cmd` shims for cmd. - It shows the unified diff and writes only after `y`. - Rerunning replaces only its own block. @@ -99,7 +108,10 @@ statusline and hook back out of `settings.json`: - It refuses the account you're on, the last one, and the first one while others link into it. 8. **`rename`, `list` and `migrate`.** - `rename ` changes the name and the shell commands; the dir is not moved. - - `list` shows each account's colour, dir, last use and which one you're on. + - `list` shows one line per account: colour, logged in or out, email, last seen session and weekly usage with time left, last use, dir, and which one you're on. + - Logged in means the account's dir holds `.credentials.json`. On macOS, where the login lives in the Keychain (never read), an `oauthAccount` in its `.claude.json` counts instead. + - The email comes from that `.claude.json` (`~/.claude.json` for `~/.claude`), `-` when there is none. + - Usage comes from the statusline's cache: `-` before the account has reported any, `0%` once a window has reset. - `migrate` registers `~/.claude` and every `~/.claude-` dir holding `.credentials.json`, `.claude.json` or `projects/`; it links nothing, and once setup has run it adds the statusline and hook to each one's `settings.json`. - Renaming the account you're on is fine: the launcher looks it up by its dir at the next switch. 9. **Statusline.** Setup also writes a `statusLine` (refreshed every 60 s), and the hook below, into every account's `settings.json`. @@ -112,7 +124,8 @@ statusline and hook back out of `settings.json`: - With two or more accounts, a `UserPromptSubmit` hook warns once when the account crosses 90% and once at 97% in a window: `work at 91%: run /switch personal`, naming the account with the most room (a window past its reset counts as empty). 11. **`/switch` at 100%.** The same hook catches a typed `/switch` or `/switch `, writes the launcher's request and blocks the prompt, so the switch makes no API call and works on an account that is out of usage. - Claude Code gives this hook the line exactly as typed, before the skill expands (hooks reference, section "UserPromptSubmit input"; blocking per "UserPromptSubmit decision control"). - - `add`, `remove`, `rename`, `list` and `theme` still go through the skill. + - It answers `/switch list` too, with or without the launcher, so the list also needs no API call. + - `setup`, `add`, `remove`, `rename` and `theme` still go through the skill. 12. **Theme and title.** `/switch theme ` gives an account its own Claude theme. - The launcher passes it as `--settings '{"theme":...}'`, merged with a `--settings` you passed yourself (yours wins on the same key). - It also sets the terminal tab title to `claude: ` at each start and switch, and prints the switch line in the target account's colour. @@ -246,6 +259,15 @@ The same conversation reopens on `personal`. - `/switch nope` instead prints `unknown account 'nope'. accounts: work (current), personal` and does nothing. - `/switch personal now` (anything after the name but `yes`) prints `usage: /switch [name]`. +``` +/switch list +``` + +``` +work cyan logged in sam@example.com session 42% (2h13m left) week 18% (4d6h left) last used 2026-10-17 09:12 UTC /home/sam/.claude (current) +personal magenta logged out - session - week - last used never /home/sam/.claude-personal +``` + ## Related - [`session-handoff`](../kanban/session-handoff/README.md): when the @@ -254,4 +276,4 @@ The same conversation reopens on `personal`. ## Prereqs -Python 3.8+ (stdlib only) and Claude Code on `PATH`. Run `setup` once. +Python 3.8+ (stdlib only) and Claude Code on `PATH`. Run `/switch setup` once. From 98072bf82a5d8966cf8cb8d63a38c197d7dfc239 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sat, 17 Oct 2026 02:46:14 -0400 Subject: [PATCH 5/5] docs(switch): clearer rename hint (#234) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WHwM2TcQBo9oHdCrYCtdMn --- switch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/switch/README.md b/switch/README.md index 9f9af1a..4c9c17e 100644 --- a/switch/README.md +++ b/switch/README.md @@ -29,7 +29,7 @@ Runs several Claude Code accounts side by side, each with its own config dir, an 1. Install the skills: `scripts/install-skills.sh` (on Windows, `scripts/install-skills.ps1`). 2. Start Claude as usual and type `/switch setup work` to name the account you're on `work` (plain `/switch setup` calls it `main`). - Claude shows the shell profile and settings changes it would make and asks "Write these changes?". Nothing is written until you say yes. - - Rename the account later with `/switch rename main work`. + - Called it `main` and want another name? `/switch rename main work`. 3. Open a new terminal, because shell profiles only load in new shells (or reload with `source ~/.bashrc` / `. $PROFILE`). Then run `claude` (or `claude-work`). 4. Inside that session, run `/switch add personal`. - It creates `~/.claude-personal`, links your first account's skills, settings and history into it, and reopens this conversation there.