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.
- claude_account-0.1.0/.github/workflows/ci.yml +27 -0
- claude_account-0.1.0/.github/workflows/publish-pypi.yml +46 -0
- claude_account-0.1.0/.gitignore +15 -0
- claude_account-0.1.0/LICENSE +21 -0
- claude_account-0.1.0/PKG-INFO +127 -0
- claude_account-0.1.0/README.md +107 -0
- claude_account-0.1.0/pyproject.toml +37 -0
- claude_account-0.1.0/src/claude_account/__init__.py +1 -0
- claude_account-0.1.0/src/claude_account/cli.py +348 -0
- claude_account-0.1.0/tests/test_cli.py +104 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/claude-account/)
|
|
24
|
+
[](https://pypi.org/project/claude-account/)
|
|
25
|
+
[](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml)
|
|
26
|
+
[](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
|
+
[](https://pypi.org/project/claude-account/)
|
|
4
|
+
[](https://pypi.org/project/claude-account/)
|
|
5
|
+
[](https://github.com/Barsoomx/claude-account/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|