claude-account 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ matrix:
17
+ python-version: ['3.9', '3.12']
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+
26
+ - name: Run tests
27
+ run: uv run --group dev pytest -q
@@ -0,0 +1,46 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - name: Install uv
13
+ uses: astral-sh/setup-uv@v5
14
+ - name: Run tests
15
+ run: uv run --group dev pytest -q
16
+
17
+ build:
18
+ needs: test
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ - name: Build sdist and wheel
25
+ run: uv build
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ # Trusted Publishing (OIDC) — must match the PyPI publisher config.
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/p/claude-account
38
+ permissions:
39
+ id-token: write
40
+ steps:
41
+ - uses: actions/download-artifact@v4
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+ - name: Publish to PyPI
46
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ # build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.pyc
7
+ .venv/
8
+ uv.lock
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+
12
+ # NEVER commit real Claude credentials / captured slots
13
+ .claude-accounts/
14
+ *.credentials.json
15
+ .last-active.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filipp Balakin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-account
3
+ Version: 0.1.0
4
+ Summary: Manually switch between multiple Claude Code accounts by swapping local credentials.
5
+ Project-URL: Homepage, https://github.com/Barsoomx/claude-account
6
+ Project-URL: Repository, https://github.com/Barsoomx/claude-account
7
+ Project-URL: Issues, https://github.com/Barsoomx/claude-account/issues
8
+ Author: Filipp Balakin
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: account,claude,claude-code,cli,credentials,oauth
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # claude-account
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/claude-account.svg)](https://pypi.org/project/claude-account/)
24
+ [![Python versions](https://img.shields.io/pypi/pyversions/claude-account.svg)](https://pypi.org/project/claude-account/)
25
+ [![CI](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml/badge.svg)](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
27
+
28
+ Manually switch between multiple **Claude Code** accounts (e.g. a personal and a
29
+ work subscription) from the terminal — without signing out and back in.
30
+
31
+ It swaps **only** the subscription token and account identity, leaving your MCP
32
+ server auth and all project state untouched:
33
+
34
+ - `claudeAiOauth` in `~/.claude/.credentials.json` — the subscription token
35
+ - `oauthAccount` in `~/.claude.json` — the account identity
36
+
37
+ Everything else — `mcpOAuth`, project history, settings — is preserved. Each
38
+ switch backs up your host files first.
39
+
40
+ > **Disclaimer.** This is an unofficial, community tool. It is **not affiliated
41
+ > with, endorsed by, or sponsored by Anthropic**. "Claude" and "Anthropic" are
42
+ > trademarks of Anthropic, PBC. It only shuffles credential files that already
43
+ > live on your own machine; it talks to no Anthropic service itself. Use it to
44
+ > switch between accounts **you personally own**, manually. Don't use it to
45
+ > automate around usage limits.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ # run without installing
51
+ uvx claude-account status
52
+
53
+ # or install as a persistent tool
54
+ uv tool install claude-account
55
+ # or
56
+ pipx install claude-account
57
+ ```
58
+
59
+ Requires the [`claude`](https://claude.com/claude-code) CLI on your `PATH`
60
+ (only for `capture`) and Python 3.9+.
61
+
62
+ ## Quick start
63
+
64
+ Two accounts, called `primary` and `backup`:
65
+
66
+ ```bash
67
+ claude-account snapshot primary # store the account you're already on
68
+ claude-account capture backup # opens an isolated login for the 2nd account
69
+ claude-account swap # flip between them
70
+ ```
71
+
72
+ Handy shell aliases:
73
+
74
+ ```bash
75
+ alias cas='claude-account swap'
76
+ alias cass='claude-account status'
77
+ ```
78
+
79
+ ## Commands
80
+
81
+ | Command | What it does |
82
+ | --- | --- |
83
+ | `snapshot <slot>` | Store the account you are **already** logged into (no re-login). |
84
+ | `capture <slot>` | Open an isolated login booth and store **another** account. |
85
+ | `status` | Show the active account and stored slots. |
86
+ | `swap` | Toggle between the two stored slots. |
87
+ | `use <slot>` | Activate a specific slot. |
88
+
89
+ Slots live in `~/.claude-accounts/`; host backups in
90
+ `~/.claude-accounts/backups/`. Paths can be overridden with
91
+ `CLAUDE_CRED_FILE`, `CLAUDE_CONFIG_FILE`, and `CLAUDE_ACCOUNTS_DIR`.
92
+
93
+ ## How it works
94
+
95
+ `capture` logs in inside a throwaway `CLAUDE_CONFIG_DIR`, so your host login is
96
+ never touched, then copies out just that account's `claudeAiOauth` +
97
+ `oauthAccount`. `swap`/`use` do the reverse surgically: they write the target
98
+ slot's `claudeAiOauth` into `.credentials.json` and its `oauthAccount` into
99
+ `.claude.json`, preserving every other key. Before switching away, the current
100
+ account's live tokens are saved back into its slot (Claude Code rotates refresh
101
+ tokens, so stale copies would eventually stop working).
102
+
103
+ Do a switch **between** `claude` sessions — a running session holds its token in
104
+ memory. Slot files contain refresh tokens; treat them as secrets (they are
105
+ written `0600` and `~/.claude-accounts/` is `0700`).
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ uv run --group dev pytest -q
111
+ uv build
112
+ ```
113
+
114
+ ## Releasing
115
+
116
+ Tag a version and push; CI builds and publishes to PyPI via
117
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no
118
+ tokens):
119
+
120
+ ```bash
121
+ git tag v0.1.0
122
+ git push origin v0.1.0
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,107 @@
1
+ # claude-account
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/claude-account.svg)](https://pypi.org/project/claude-account/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/claude-account.svg)](https://pypi.org/project/claude-account/)
5
+ [![CI](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml/badge.svg)](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+
8
+ Manually switch between multiple **Claude Code** accounts (e.g. a personal and a
9
+ work subscription) from the terminal — without signing out and back in.
10
+
11
+ It swaps **only** the subscription token and account identity, leaving your MCP
12
+ server auth and all project state untouched:
13
+
14
+ - `claudeAiOauth` in `~/.claude/.credentials.json` — the subscription token
15
+ - `oauthAccount` in `~/.claude.json` — the account identity
16
+
17
+ Everything else — `mcpOAuth`, project history, settings — is preserved. Each
18
+ switch backs up your host files first.
19
+
20
+ > **Disclaimer.** This is an unofficial, community tool. It is **not affiliated
21
+ > with, endorsed by, or sponsored by Anthropic**. "Claude" and "Anthropic" are
22
+ > trademarks of Anthropic, PBC. It only shuffles credential files that already
23
+ > live on your own machine; it talks to no Anthropic service itself. Use it to
24
+ > switch between accounts **you personally own**, manually. Don't use it to
25
+ > automate around usage limits.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ # run without installing
31
+ uvx claude-account status
32
+
33
+ # or install as a persistent tool
34
+ uv tool install claude-account
35
+ # or
36
+ pipx install claude-account
37
+ ```
38
+
39
+ Requires the [`claude`](https://claude.com/claude-code) CLI on your `PATH`
40
+ (only for `capture`) and Python 3.9+.
41
+
42
+ ## Quick start
43
+
44
+ Two accounts, called `primary` and `backup`:
45
+
46
+ ```bash
47
+ claude-account snapshot primary # store the account you're already on
48
+ claude-account capture backup # opens an isolated login for the 2nd account
49
+ claude-account swap # flip between them
50
+ ```
51
+
52
+ Handy shell aliases:
53
+
54
+ ```bash
55
+ alias cas='claude-account swap'
56
+ alias cass='claude-account status'
57
+ ```
58
+
59
+ ## Commands
60
+
61
+ | Command | What it does |
62
+ | --- | --- |
63
+ | `snapshot <slot>` | Store the account you are **already** logged into (no re-login). |
64
+ | `capture <slot>` | Open an isolated login booth and store **another** account. |
65
+ | `status` | Show the active account and stored slots. |
66
+ | `swap` | Toggle between the two stored slots. |
67
+ | `use <slot>` | Activate a specific slot. |
68
+
69
+ Slots live in `~/.claude-accounts/`; host backups in
70
+ `~/.claude-accounts/backups/`. Paths can be overridden with
71
+ `CLAUDE_CRED_FILE`, `CLAUDE_CONFIG_FILE`, and `CLAUDE_ACCOUNTS_DIR`.
72
+
73
+ ## How it works
74
+
75
+ `capture` logs in inside a throwaway `CLAUDE_CONFIG_DIR`, so your host login is
76
+ never touched, then copies out just that account's `claudeAiOauth` +
77
+ `oauthAccount`. `swap`/`use` do the reverse surgically: they write the target
78
+ slot's `claudeAiOauth` into `.credentials.json` and its `oauthAccount` into
79
+ `.claude.json`, preserving every other key. Before switching away, the current
80
+ account's live tokens are saved back into its slot (Claude Code rotates refresh
81
+ tokens, so stale copies would eventually stop working).
82
+
83
+ Do a switch **between** `claude` sessions — a running session holds its token in
84
+ memory. Slot files contain refresh tokens; treat them as secrets (they are
85
+ written `0600` and `~/.claude-accounts/` is `0700`).
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ uv run --group dev pytest -q
91
+ uv build
92
+ ```
93
+
94
+ ## Releasing
95
+
96
+ Tag a version and push; CI builds and publishes to PyPI via
97
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no
98
+ tokens):
99
+
100
+ ```bash
101
+ git tag v0.1.0
102
+ git push origin v0.1.0
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ['hatchling']
3
+ build-backend = 'hatchling.build'
4
+
5
+ [project]
6
+ name = 'claude-account'
7
+ version = '0.1.0'
8
+ description = 'Manually switch between multiple Claude Code accounts by swapping local credentials.'
9
+ readme = 'README.md'
10
+ requires-python = '>=3.9'
11
+ license = 'MIT'
12
+ license-files = ['LICENSE']
13
+ authors = [{ name = 'Filipp Balakin' }]
14
+ keywords = ['claude', 'claude-code', 'cli', 'account', 'credentials', 'oauth']
15
+ classifiers = [
16
+ 'Environment :: Console',
17
+ 'Intended Audience :: Developers',
18
+ 'License :: OSI Approved :: MIT License',
19
+ 'Operating System :: POSIX',
20
+ 'Programming Language :: Python :: 3',
21
+ 'Topic :: Utilities',
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = 'https://github.com/Barsoomx/claude-account'
27
+ Repository = 'https://github.com/Barsoomx/claude-account'
28
+ Issues = 'https://github.com/Barsoomx/claude-account/issues'
29
+
30
+ [project.scripts]
31
+ claude-account = 'claude_account.cli:main'
32
+
33
+ [dependency-groups]
34
+ dev = ['pytest>=8']
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ['src/claude_account']
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
@@ -0,0 +1,348 @@
1
+ """Switch between multiple Claude Code accounts by swapping local credentials.
2
+
3
+ Only the subscription token (``claudeAiOauth``) and the account identity
4
+ (``oauthAccount``) are swapped. MCP auth (``mcpOAuth``) and all project state
5
+ in ``~/.claude.json`` are preserved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from claude_account import __version__
22
+
23
+ _SLOT_RE = re.compile(r'[a-z0-9_-]+')
24
+
25
+
26
+ class AccountError(Exception):
27
+ pass
28
+
29
+
30
+ def _home() -> Path:
31
+ return Path.home()
32
+
33
+
34
+ def cred_file() -> Path:
35
+ return Path(os.environ.get('CLAUDE_CRED_FILE', _home() / '.claude' / '.credentials.json'))
36
+
37
+
38
+ def config_file() -> Path:
39
+ return Path(os.environ.get('CLAUDE_CONFIG_FILE', _home() / '.claude.json'))
40
+
41
+
42
+ def accounts_dir() -> Path:
43
+ return Path(os.environ.get('CLAUDE_ACCOUNTS_DIR', _home() / '.claude-accounts'))
44
+
45
+
46
+ def backups_dir() -> Path:
47
+ return accounts_dir() / 'backups'
48
+
49
+
50
+ def _ts() -> str:
51
+ return datetime.now().strftime('%Y%m%d-%H%M%S')
52
+
53
+
54
+ def _valid_slot(name: str) -> bool:
55
+ return bool(name) and _SLOT_RE.fullmatch(name) is not None
56
+
57
+
58
+ def _load_json(path: Path):
59
+ try:
60
+ with path.open() as fh:
61
+ return json.load(fh)
62
+ except FileNotFoundError:
63
+ return None
64
+
65
+
66
+ def _atomic_write_json(path: Path, obj) -> None:
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.tmp.')
69
+ try:
70
+ with os.fdopen(fd, 'w') as fh:
71
+ json.dump(obj, fh, indent=2)
72
+ fh.write('\n')
73
+ os.chmod(tmp, 0o600)
74
+ os.replace(tmp, path)
75
+ except BaseException:
76
+ try:
77
+ os.unlink(tmp)
78
+ except OSError:
79
+ pass
80
+
81
+ raise
82
+
83
+
84
+ def _dig(obj, *keys):
85
+ for key in keys:
86
+ if not isinstance(obj, dict):
87
+ return None
88
+
89
+ obj = obj.get(key)
90
+
91
+ return obj
92
+
93
+
94
+ def _ensure_accounts_dir() -> None:
95
+ accounts_dir().mkdir(parents=True, exist_ok=True)
96
+ try:
97
+ os.chmod(accounts_dir(), 0o700)
98
+ except OSError:
99
+ pass
100
+
101
+
102
+ def _slot_path(name: str) -> Path:
103
+ return accounts_dir() / f'{name}.json'
104
+
105
+
106
+ def _iter_slots() -> list[Path]:
107
+ directory = accounts_dir()
108
+ if not directory.is_dir():
109
+ return []
110
+
111
+ return sorted(p for p in directory.glob('*.json') if not p.name.startswith('.'))
112
+
113
+
114
+ def _slot_for_uuid(uuid: str):
115
+ if not uuid:
116
+ return None
117
+
118
+ for path in _iter_slots():
119
+ data = _load_json(path) or {}
120
+ if data.get('accountUuid') == uuid:
121
+ return path.stem
122
+
123
+ return None
124
+
125
+
126
+ def _build_slot(cred, cfg, slot: str) -> dict:
127
+ return {
128
+ 'slot': slot,
129
+ 'label': _dig(cfg, 'oauthAccount', 'emailAddress') or 'unknown',
130
+ 'accountUuid': _dig(cfg, 'oauthAccount', 'accountUuid') or '',
131
+ 'subscriptionType': _dig(cred, 'claudeAiOauth', 'subscriptionType') or 'unknown',
132
+ 'capturedAt': _ts(),
133
+ 'claudeAiOauth': _dig(cred, 'claudeAiOauth'),
134
+ 'oauthAccount': _dig(cfg, 'oauthAccount'),
135
+ }
136
+
137
+
138
+ def _prune_backups(keep: int = 10) -> None:
139
+ directory = backups_dir()
140
+ if not directory.is_dir():
141
+ return
142
+
143
+ dirs = sorted(
144
+ (p for p in directory.iterdir() if p.is_dir()),
145
+ key=lambda p: p.stat().st_mtime,
146
+ reverse=True,
147
+ )
148
+ for path in dirs[keep:]:
149
+ shutil.rmtree(path, ignore_errors=True)
150
+
151
+
152
+ def cmd_capture(slot: str) -> None:
153
+ if not _valid_slot(slot):
154
+ raise AccountError('usage: claude-account capture <slot> (slot = lowercase letters/digits/-/_)')
155
+
156
+ if not shutil.which('claude'):
157
+ raise AccountError("'claude' CLI not found in PATH")
158
+
159
+ _ensure_accounts_dir()
160
+ booth = Path(tempfile.mkdtemp(prefix='claude-booth.'))
161
+ try:
162
+ print(f"== Login booth for slot '{slot}' ==", file=sys.stderr)
163
+ print('A fresh, isolated Claude Code will open — your host login is NOT touched.', file=sys.stderr)
164
+ print(f" 1) Log in with the account you want to store in '{slot}'.", file=sys.stderr)
165
+ print(' 2) At the prompt, type /exit (or Ctrl+C) to come back here.', file=sys.stderr)
166
+ env = dict(os.environ, CLAUDE_CONFIG_DIR=str(booth))
167
+ subprocess.run(['claude'], env=env)
168
+ cred = _load_json(booth / '.credentials.json')
169
+ if not _dig(cred, 'claudeAiOauth', 'accessToken'):
170
+ raise AccountError('login not completed (no OAuth token captured)')
171
+
172
+ cfg = _load_json(booth / '.claude.json')
173
+ slot_obj = _build_slot(cred, cfg, slot)
174
+ _atomic_write_json(_slot_path(slot), slot_obj)
175
+ print(f"captured slot '{slot}': {slot_obj['label']} [{slot_obj['subscriptionType']}]")
176
+ finally:
177
+ shutil.rmtree(booth, ignore_errors=True)
178
+
179
+
180
+ def cmd_snapshot(slot: str) -> None:
181
+ if not _valid_slot(slot):
182
+ raise AccountError('usage: claude-account snapshot <slot>')
183
+
184
+ cred = _load_json(cred_file())
185
+ if not _dig(cred, 'claudeAiOauth', 'accessToken'):
186
+ raise AccountError(f"no active credentials at {cred_file()} — log in with 'claude' first")
187
+
188
+ cfg = _load_json(config_file())
189
+ _ensure_accounts_dir()
190
+ slot_obj = _build_slot(cred, cfg, slot)
191
+ _atomic_write_json(_slot_path(slot), slot_obj)
192
+ print(f"snapshotted current host account into slot '{slot}': {slot_obj['label']}")
193
+
194
+
195
+ def cmd_status() -> None:
196
+ _ensure_accounts_dir()
197
+ cfg = _load_json(config_file())
198
+ huuid = _dig(cfg, 'oauthAccount', 'accountUuid') or ''
199
+ hemail = _dig(cfg, 'oauthAccount', 'emailAddress') or ''
200
+ active = _slot_for_uuid(huuid)
201
+ print(f"host active account: {hemail or '<none>'}")
202
+ if huuid and not active:
203
+ print(' (not captured in any slot — run: claude-account capture <slot>)')
204
+
205
+ print()
206
+ print(f'slots in {accounts_dir()}:')
207
+ slots = _iter_slots()
208
+ if not slots:
209
+ print(' (none yet)')
210
+
211
+ return
212
+
213
+ for path in slots:
214
+ data = _load_json(path) or {}
215
+ mark = '*' if path.stem == active else ' '
216
+ label = data.get('label', '?')
217
+ sub = data.get('subscriptionType', '?')
218
+ at = data.get('capturedAt', '?')
219
+ print(f' {mark} {path.stem:<10} {label:<30} [{sub}] captured {at}')
220
+
221
+
222
+ def cmd_apply(target: str) -> None:
223
+ if not _valid_slot(target):
224
+ raise AccountError(f"invalid slot name '{target}'")
225
+
226
+ tobj = _load_json(_slot_path(target))
227
+ if tobj is None:
228
+ raise AccountError(f"slot '{target}' not found (capture it first: claude-account capture {target})")
229
+
230
+ cfg = _load_json(config_file())
231
+ if cfg is None:
232
+ raise AccountError(f'host config {config_file()} not found')
233
+
234
+ tuuid = tobj.get('accountUuid') or ''
235
+ tlabel = tobj.get('label') or 'unknown'
236
+ huuid = _dig(cfg, 'oauthAccount', 'accountUuid') or ''
237
+ if huuid and huuid == tuuid:
238
+ print(f"already on '{target}' ({tlabel}) — nothing to do")
239
+
240
+ return
241
+
242
+ _ensure_accounts_dir()
243
+ cred = _load_json(cred_file())
244
+
245
+ if cred is not None:
246
+ _atomic_write_json(accounts_dir() / '.last-active.json', _build_slot(cred, cfg, '_last-active'))
247
+
248
+ cur = _slot_for_uuid(huuid)
249
+ if cur and cred is not None:
250
+ _atomic_write_json(_slot_path(cur), _build_slot(cred, cfg, cur))
251
+ print(f"saved current live tokens back into slot '{cur}'", file=sys.stderr)
252
+ elif huuid:
253
+ print(
254
+ "warning: current host account isn't in any slot; "
255
+ 'its live tokens are only in .last-active.json',
256
+ file=sys.stderr,
257
+ )
258
+
259
+ bdir = backups_dir() / _ts()
260
+ bdir.mkdir(parents=True, exist_ok=True)
261
+ if cred_file().exists():
262
+ shutil.copy2(cred_file(), bdir / 'credentials.json')
263
+
264
+ shutil.copy2(config_file(), bdir / 'claude.json')
265
+ _prune_backups(keep=10)
266
+
267
+ new_cred = cred if isinstance(cred, dict) else {}
268
+ new_cred['claudeAiOauth'] = tobj.get('claudeAiOauth')
269
+ _atomic_write_json(cred_file(), new_cred)
270
+
271
+ cfg['oauthAccount'] = tobj.get('oauthAccount')
272
+ _atomic_write_json(config_file(), cfg)
273
+
274
+ print(f"switched to '{target}' ({tlabel})")
275
+ print("restart any running 'claude' session for the change to take effect", file=sys.stderr)
276
+
277
+
278
+ def cmd_swap() -> None:
279
+ slots = _iter_slots()
280
+ if len(slots) < 2:
281
+ raise AccountError(
282
+ f'swap needs 2 captured slots (have {len(slots)}). Run: claude-account capture <slot>'
283
+ )
284
+
285
+ if len(slots) > 2:
286
+ raise AccountError('more than 2 slots present — pick one: claude-account use <slot>')
287
+
288
+ cfg = _load_json(config_file())
289
+ huuid = _dig(cfg, 'oauthAccount', 'accountUuid') or ''
290
+ cur = _slot_for_uuid(huuid)
291
+ if not cur:
292
+ raise AccountError('current host account matches no slot — pick explicitly: claude-account use <slot>')
293
+
294
+ target = next(p.stem for p in slots if p.stem != cur)
295
+ cmd_apply(target)
296
+
297
+
298
+ def _build_parser() -> argparse.ArgumentParser:
299
+ parser = argparse.ArgumentParser(
300
+ prog='claude-account',
301
+ description='Manually switch between multiple Claude Code accounts (subscriptions).',
302
+ )
303
+ parser.add_argument('-V', '--version', action='version', version=f'claude-account {__version__}')
304
+ sub = parser.add_subparsers(dest='cmd')
305
+
306
+ p_capture = sub.add_parser('capture', help='log in (isolated) and store ANOTHER account into <slot>')
307
+ p_capture.add_argument('slot')
308
+
309
+ p_snapshot = sub.add_parser('snapshot', aliases=['save'], help='store the account you are ALREADY logged into')
310
+ p_snapshot.add_argument('slot')
311
+
312
+ sub.add_parser('status', aliases=['st'], help='show the active account and stored slots')
313
+ sub.add_parser('swap', aliases=['toggle'], help='toggle between the two stored slots')
314
+
315
+ p_use = sub.add_parser('use', aliases=['switch'], help='activate a specific slot')
316
+ p_use.add_argument('slot')
317
+
318
+ return parser
319
+
320
+
321
+ def main(argv=None) -> int:
322
+ parser = _build_parser()
323
+ args = parser.parse_args(argv)
324
+ try:
325
+ if args.cmd == 'capture':
326
+ cmd_capture(args.slot)
327
+ elif args.cmd in ('snapshot', 'save'):
328
+ cmd_snapshot(args.slot)
329
+ elif args.cmd in ('status', 'st'):
330
+ cmd_status()
331
+ elif args.cmd in ('swap', 'toggle'):
332
+ cmd_swap()
333
+ elif args.cmd in ('use', 'switch'):
334
+ cmd_apply(args.slot)
335
+ else:
336
+ parser.print_help(sys.stderr)
337
+
338
+ return 1
339
+ except AccountError as exc:
340
+ print(f'error: {exc}', file=sys.stderr)
341
+
342
+ return 1
343
+
344
+ return 0
345
+
346
+
347
+ if __name__ == '__main__':
348
+ raise SystemExit(main())
@@ -0,0 +1,104 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from claude_account import cli
7
+
8
+
9
+ def _write(path: Path, obj) -> None:
10
+ path.parent.mkdir(parents=True, exist_ok=True)
11
+ path.write_text(json.dumps(obj))
12
+
13
+
14
+ def _load(path: Path):
15
+ return json.loads(Path(path).read_text())
16
+
17
+
18
+ @pytest.fixture
19
+ def f_env(tmp_path, monkeypatch):
20
+ cred = tmp_path / 'host' / '.claude' / '.credentials.json'
21
+ cfg = tmp_path / 'host' / '.claude.json'
22
+ acc = tmp_path / 'accounts'
23
+ monkeypatch.setenv('CLAUDE_CRED_FILE', str(cred))
24
+ monkeypatch.setenv('CLAUDE_CONFIG_FILE', str(cfg))
25
+ monkeypatch.setenv('CLAUDE_ACCOUNTS_DIR', str(acc))
26
+
27
+ _write(cred, {
28
+ 'claudeAiOauth': {
29
+ 'accessToken': 'ACCESS-A', 'refreshToken': 'REFRESH-A', 'subscriptionType': 'max',
30
+ },
31
+ 'mcpOAuth': {'gitlab': {'access_token': 'MCP-SECRET'}},
32
+ })
33
+ _write(cfg, {
34
+ 'oauthAccount': {'accountUuid': 'UUID-A', 'emailAddress': 'a@example.com'},
35
+ 'projects': {'/x': {'history': [1, 2, 3]}},
36
+ 'numStartups': 42,
37
+ })
38
+ _write(acc / 'primary.json', {
39
+ 'slot': 'primary', 'label': 'a@example.com', 'accountUuid': 'UUID-A',
40
+ 'subscriptionType': 'max', 'capturedAt': 't0',
41
+ 'claudeAiOauth': {'accessToken': 'ACCESS-A', 'refreshToken': 'REFRESH-A', 'subscriptionType': 'max'},
42
+ 'oauthAccount': {'accountUuid': 'UUID-A', 'emailAddress': 'a@example.com'},
43
+ })
44
+ _write(acc / 'backup.json', {
45
+ 'slot': 'backup', 'label': 'b@example.com', 'accountUuid': 'UUID-B',
46
+ 'subscriptionType': 'max', 'capturedAt': 't0',
47
+ 'claudeAiOauth': {'accessToken': 'ACCESS-B', 'refreshToken': 'REFRESH-B', 'subscriptionType': 'max'},
48
+ 'oauthAccount': {'accountUuid': 'UUID-B', 'emailAddress': 'b@example.com'},
49
+ })
50
+ return {'cred': cred, 'cfg': cfg, 'acc': acc}
51
+
52
+
53
+ def test_swap_switches_subscription_and_preserves_the_rest(f_env):
54
+ host = _load(f_env['cred'])
55
+ host['claudeAiOauth']['refreshToken'] = 'REFRESH-A-ROTATED'
56
+ f_env['cred'].write_text(json.dumps(host))
57
+
58
+ assert cli.main(['swap']) == 0
59
+
60
+ cred = _load(f_env['cred'])
61
+ assert cred['claudeAiOauth']['accessToken'] == 'ACCESS-B'
62
+ assert cred['mcpOAuth']['gitlab']['access_token'] == 'MCP-SECRET'
63
+
64
+ cfg = _load(f_env['cfg'])
65
+ assert cfg['oauthAccount']['emailAddress'] == 'b@example.com'
66
+ assert cfg['projects']['/x']['history'] == [1, 2, 3]
67
+ assert cfg['numStartups'] == 42
68
+
69
+ primary = _load(f_env['acc'] / 'primary.json')
70
+ assert primary['claudeAiOauth']['refreshToken'] == 'REFRESH-A-ROTATED'
71
+
72
+
73
+ def test_status_marks_active_slot(f_env, capsys):
74
+ assert cli.main(['status']) == 0
75
+ assert '* primary' in capsys.readouterr().out
76
+
77
+
78
+ def test_swap_roundtrip_returns_to_original(f_env):
79
+ cli.main(['swap'])
80
+ cli.main(['swap'])
81
+ assert _load(f_env['cred'])['claudeAiOauth']['accessToken'] == 'ACCESS-A'
82
+
83
+
84
+ def test_use_current_slot_is_noop(f_env, capsys):
85
+ assert cli.main(['use', 'primary']) == 0
86
+ assert 'already on' in capsys.readouterr().out
87
+
88
+
89
+ def test_snapshot_stores_current_host_account(f_env):
90
+ assert cli.main(['snapshot', 'third']) == 0
91
+ third = _load(f_env['acc'] / 'third.json')
92
+ assert third['accountUuid'] == 'UUID-A'
93
+ assert third['claudeAiOauth']['accessToken'] == 'ACCESS-A'
94
+
95
+
96
+ def test_swap_creates_a_backup(f_env):
97
+ cli.main(['swap'])
98
+ backups = f_env['acc'] / 'backups'
99
+ assert backups.is_dir() and any(backups.iterdir())
100
+
101
+
102
+ def test_use_unknown_slot_errors(f_env, capsys):
103
+ assert cli.main(['use', 'nope']) == 1
104
+ assert 'not found' in capsys.readouterr().err