claude-select 0.1.0__py3-none-any.whl
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_select/__init__.py +5 -0
- claude_select/__main__.py +8 -0
- claude_select/cli.py +115 -0
- claude_select/exceptions.py +29 -0
- claude_select/live_state.py +176 -0
- claude_select/locking.py +65 -0
- claude_select/manager.py +260 -0
- claude_select/models.py +140 -0
- claude_select/oauth.py +151 -0
- claude_select/paths.py +45 -0
- claude_select/store.py +136 -0
- claude_select-0.1.0.dist-info/METADATA +478 -0
- claude_select-0.1.0.dist-info/RECORD +17 -0
- claude_select-0.1.0.dist-info/WHEEL +5 -0
- claude_select-0.1.0.dist-info/entry_points.txt +2 -0
- claude_select-0.1.0.dist-info/licenses/LICENSE +22 -0
- claude_select-0.1.0.dist-info/top_level.txt +1 -0
claude_select/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command line interface for claude-select."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from claude_select.exceptions import ClaudeSwitchError
|
|
10
|
+
from claude_select.manager import ProfileManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Create the CLI parser."""
|
|
15
|
+
parser = argparse.ArgumentParser(description="Manage multiple Claude auth profiles.")
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
capture = subparsers.add_parser("capture", help="Capture the current Claude CLI login state.")
|
|
19
|
+
capture.add_argument("profile")
|
|
20
|
+
|
|
21
|
+
sync = subparsers.add_parser(
|
|
22
|
+
"sync",
|
|
23
|
+
help="Sync a stored profile from the current Claude CLI login state.",
|
|
24
|
+
)
|
|
25
|
+
sync.add_argument("profile", nargs="?")
|
|
26
|
+
|
|
27
|
+
list_cmd = subparsers.add_parser("list", help="List stored profiles.")
|
|
28
|
+
list_cmd.add_argument("--json", action="store_true", dest="as_json")
|
|
29
|
+
|
|
30
|
+
current = subparsers.add_parser("current", help="Show current CLI and default SDK profiles.")
|
|
31
|
+
current.add_argument("--json", action="store_true", dest="as_json")
|
|
32
|
+
|
|
33
|
+
use = subparsers.add_parser("use", help="Switch Claude CLI live state to a profile.")
|
|
34
|
+
use.add_argument("profile")
|
|
35
|
+
|
|
36
|
+
remove = subparsers.add_parser("remove", help="Remove a stored profile.")
|
|
37
|
+
remove.add_argument("profile")
|
|
38
|
+
|
|
39
|
+
set_default = subparsers.add_parser("set-default-sdk", help="Set the default SDK profile.")
|
|
40
|
+
set_default.add_argument("profile")
|
|
41
|
+
|
|
42
|
+
inspect = subparsers.add_parser("inspect", help="Inspect one stored profile.")
|
|
43
|
+
inspect.add_argument("profile")
|
|
44
|
+
inspect.add_argument("--json", action="store_true", dest="as_json")
|
|
45
|
+
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
"""Run the CLI."""
|
|
51
|
+
parser = build_parser()
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
manager = ProfileManager()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
if args.command == "capture":
|
|
57
|
+
result = manager.capture_cli_profile(args.profile)
|
|
58
|
+
print(f"Captured profile '{result['id']}' for {result['email']}.")
|
|
59
|
+
return 0
|
|
60
|
+
if args.command == "sync":
|
|
61
|
+
result = manager.sync_cli_profile(args.profile)
|
|
62
|
+
print(f"Synchronized profile '{result['id']}'.")
|
|
63
|
+
return 0
|
|
64
|
+
if args.command == "list":
|
|
65
|
+
profiles = manager.list_profiles()
|
|
66
|
+
if args.as_json:
|
|
67
|
+
print(json.dumps(profiles, indent=2, sort_keys=True))
|
|
68
|
+
else:
|
|
69
|
+
for profile in profiles:
|
|
70
|
+
print(
|
|
71
|
+
f"{profile['id']}\t{profile['email']}\t{profile['auth_state']}\t"
|
|
72
|
+
f"{profile['organization_name'] or '-'}"
|
|
73
|
+
)
|
|
74
|
+
return 0
|
|
75
|
+
if args.command == "current":
|
|
76
|
+
payload = {
|
|
77
|
+
"current_cli_profile": manager.get_current_cli_profile(),
|
|
78
|
+
"default_sdk_profile": manager.get_default_sdk_profile(),
|
|
79
|
+
}
|
|
80
|
+
if args.as_json:
|
|
81
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
82
|
+
else:
|
|
83
|
+
print(f"current_cli_profile={payload['current_cli_profile']}")
|
|
84
|
+
print(f"default_sdk_profile={payload['default_sdk_profile']}")
|
|
85
|
+
return 0
|
|
86
|
+
if args.command == "use":
|
|
87
|
+
result = manager.switch_cli(args.profile)
|
|
88
|
+
print(f"Switched CLI to profile '{result['id']}'.")
|
|
89
|
+
if result.get("refresh_error"):
|
|
90
|
+
print(
|
|
91
|
+
f"Profile requires reauthentication: {result['refresh_error']}",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
return 0
|
|
95
|
+
if args.command == "remove":
|
|
96
|
+
manager.remove_profile(args.profile)
|
|
97
|
+
print(f"Removed profile '{args.profile}'.")
|
|
98
|
+
return 0
|
|
99
|
+
if args.command == "set-default-sdk":
|
|
100
|
+
manager.set_default_sdk_profile(args.profile)
|
|
101
|
+
print(f"Default SDK profile set to '{args.profile}'.")
|
|
102
|
+
return 0
|
|
103
|
+
if args.command == "inspect":
|
|
104
|
+
profile = manager.inspect_profile(args.profile)
|
|
105
|
+
if args.as_json:
|
|
106
|
+
print(json.dumps(profile, indent=2, sort_keys=True))
|
|
107
|
+
else:
|
|
108
|
+
print(json.dumps(profile, indent=2, sort_keys=True))
|
|
109
|
+
return 0
|
|
110
|
+
except ClaudeSwitchError as exc:
|
|
111
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
parser.error(f"Unhandled command: {args.command}")
|
|
115
|
+
return 2
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Project-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClaudeSwitchError(Exception):
|
|
5
|
+
"""Base error for the package."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(ClaudeSwitchError):
|
|
9
|
+
"""Raised when local Claude configuration is invalid or missing."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProfileNotFoundError(ClaudeSwitchError):
|
|
13
|
+
"""Raised when a named profile does not exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProfileValidationError(ClaudeSwitchError):
|
|
17
|
+
"""Raised when profile input is invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProfileReauthRequired(ClaudeSwitchError):
|
|
21
|
+
"""Raised when a profile requires the user to login again."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OAuthRefreshError(ClaudeSwitchError):
|
|
25
|
+
"""Raised when an OAuth token refresh fails."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LockTimeoutError(ClaudeSwitchError):
|
|
29
|
+
"""Raised when a store lock cannot be acquired."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Claude live state backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from claude_select.exceptions import ConfigError
|
|
16
|
+
from claude_select.models import LiveState
|
|
17
|
+
from claude_select.paths import get_credentials_path, get_global_config_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CredentialStore(Protocol):
|
|
21
|
+
"""Read and write Claude credentials."""
|
|
22
|
+
|
|
23
|
+
def read(self) -> dict[str, Any]:
|
|
24
|
+
"""Read the current credentials payload."""
|
|
25
|
+
|
|
26
|
+
def write(self, credentials: dict[str, Any]) -> None:
|
|
27
|
+
"""Write the current credentials payload."""
|
|
28
|
+
|
|
29
|
+
def backup(self, destination_dir: Path) -> None:
|
|
30
|
+
"""Back up the credential store if supported."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FileCredentialStore:
|
|
34
|
+
"""File-backed Claude credential storage."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: Path):
|
|
37
|
+
self.path = path
|
|
38
|
+
|
|
39
|
+
def read(self) -> dict[str, Any]:
|
|
40
|
+
if not self.path.exists():
|
|
41
|
+
raise ConfigError(f"Claude credentials file not found: {self.path}")
|
|
42
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
43
|
+
return json.load(handle)
|
|
44
|
+
|
|
45
|
+
def write(self, credentials: dict[str, Any]) -> None:
|
|
46
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
with tempfile.NamedTemporaryFile(
|
|
48
|
+
"w",
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
dir=self.path.parent,
|
|
51
|
+
delete=False,
|
|
52
|
+
) as handle:
|
|
53
|
+
json.dump(credentials, handle, indent=2, sort_keys=True)
|
|
54
|
+
handle.write("\n")
|
|
55
|
+
temp_name = handle.name
|
|
56
|
+
os.replace(temp_name, self.path)
|
|
57
|
+
if os.name != "nt":
|
|
58
|
+
os.chmod(self.path, 0o600)
|
|
59
|
+
|
|
60
|
+
def backup(self, destination_dir: Path) -> None:
|
|
61
|
+
if not self.path.exists():
|
|
62
|
+
return
|
|
63
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
shutil.copy2(self.path, destination_dir / self.path.name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MacOSKeychainCredentialStore:
|
|
68
|
+
"""macOS keychain-backed Claude credential storage."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
service_name: str = "Claude Code-credentials",
|
|
73
|
+
account_name: str | None = None,
|
|
74
|
+
):
|
|
75
|
+
self.service_name = service_name
|
|
76
|
+
self.account_name = account_name or getpass.getuser()
|
|
77
|
+
|
|
78
|
+
def read(self) -> dict[str, Any]:
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["security", "find-generic-password", "-s", self.service_name, "-w"],
|
|
82
|
+
check=True,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
)
|
|
86
|
+
except subprocess.CalledProcessError as exc:
|
|
87
|
+
raise ConfigError("Claude credentials not found in macOS Keychain.") from exc
|
|
88
|
+
return json.loads(result.stdout)
|
|
89
|
+
|
|
90
|
+
def write(self, credentials: dict[str, Any]) -> None:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[
|
|
93
|
+
"security",
|
|
94
|
+
"add-generic-password",
|
|
95
|
+
"-U",
|
|
96
|
+
"-s",
|
|
97
|
+
self.service_name,
|
|
98
|
+
"-a",
|
|
99
|
+
self.account_name,
|
|
100
|
+
"-w",
|
|
101
|
+
json.dumps(credentials, separators=(",", ":")),
|
|
102
|
+
],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
)
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
raise ConfigError(
|
|
108
|
+
f"Failed to write macOS Keychain credentials: {result.stderr.strip()}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def backup(self, destination_dir: Path) -> None:
|
|
112
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
backup_file = destination_dir / "keychain-credentials.json"
|
|
114
|
+
backup_file.write_text(
|
|
115
|
+
json.dumps(self.read(), indent=2, sort_keys=True),
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def create_default_credential_store(env: dict[str, str] | None = None) -> CredentialStore:
|
|
121
|
+
"""Create the default credential store for the current platform."""
|
|
122
|
+
if platform.system() == "Darwin":
|
|
123
|
+
return MacOSKeychainCredentialStore()
|
|
124
|
+
return FileCredentialStore(get_credentials_path(env))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ClaudeLiveStateBackend:
|
|
128
|
+
"""Reads and writes Claude's live runtime auth state."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
config_path: Path | None = None,
|
|
133
|
+
credential_store: CredentialStore | None = None,
|
|
134
|
+
backup_dir: Path | None = None,
|
|
135
|
+
env: dict[str, str] | None = None,
|
|
136
|
+
):
|
|
137
|
+
self.config_path = config_path or get_global_config_path(env)
|
|
138
|
+
self.credential_store = credential_store or create_default_credential_store(env)
|
|
139
|
+
self.backup_dir = backup_dir or (self.config_path.parent / ".claude-select-backups")
|
|
140
|
+
|
|
141
|
+
def read(self) -> LiveState:
|
|
142
|
+
"""Read Claude's current live config and credentials."""
|
|
143
|
+
if not self.config_path.exists():
|
|
144
|
+
raise ConfigError(f"Claude config file not found: {self.config_path}")
|
|
145
|
+
with self.config_path.open("r", encoding="utf-8") as handle:
|
|
146
|
+
config = json.load(handle)
|
|
147
|
+
credentials = self.credential_store.read()
|
|
148
|
+
oauth_account = config.get("oauthAccount")
|
|
149
|
+
if not isinstance(oauth_account, dict) or not oauth_account.get("emailAddress"):
|
|
150
|
+
raise ConfigError("Claude config does not contain a valid oauthAccount.")
|
|
151
|
+
if not isinstance(credentials.get("claudeAiOauth"), dict):
|
|
152
|
+
raise ConfigError("Claude credentials do not contain a valid claudeAiOauth payload.")
|
|
153
|
+
return LiveState(config=config, credentials=credentials)
|
|
154
|
+
|
|
155
|
+
def write(self, live_state: LiveState) -> None:
|
|
156
|
+
"""Write Claude's current live config and credentials."""
|
|
157
|
+
self.backup()
|
|
158
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
with tempfile.NamedTemporaryFile(
|
|
160
|
+
"w",
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
dir=self.config_path.parent,
|
|
163
|
+
delete=False,
|
|
164
|
+
) as handle:
|
|
165
|
+
json.dump(live_state.config, handle, indent=2, sort_keys=True)
|
|
166
|
+
handle.write("\n")
|
|
167
|
+
temp_name = handle.name
|
|
168
|
+
os.replace(temp_name, self.config_path)
|
|
169
|
+
self.credential_store.write(live_state.credentials)
|
|
170
|
+
|
|
171
|
+
def backup(self) -> None:
|
|
172
|
+
"""Create backups for the live config and credentials."""
|
|
173
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
if self.config_path.exists():
|
|
175
|
+
shutil.copy2(self.config_path, self.backup_dir / self.config_path.name)
|
|
176
|
+
self.credential_store.backup(self.backup_dir)
|
claude_select/locking.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Cross-platform file locking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import IO
|
|
9
|
+
|
|
10
|
+
from claude_select.exceptions import LockTimeoutError
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
import msvcrt
|
|
14
|
+
else:
|
|
15
|
+
import fcntl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileLock:
|
|
19
|
+
"""A simple cross-process file lock."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: Path):
|
|
22
|
+
self.path = path
|
|
23
|
+
self._handle: IO[str] | None = None
|
|
24
|
+
self._locked = False
|
|
25
|
+
|
|
26
|
+
def acquire(self, timeout: float = 10.0) -> None:
|
|
27
|
+
"""Acquire an exclusive lock or raise on timeout."""
|
|
28
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self._handle = self.path.open("a+", encoding="utf-8")
|
|
30
|
+
deadline = time.monotonic() + timeout
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
if sys.platform == "win32":
|
|
34
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
35
|
+
else:
|
|
36
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
self._locked = True
|
|
38
|
+
return
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
if time.monotonic() >= deadline:
|
|
41
|
+
self.release()
|
|
42
|
+
raise LockTimeoutError(f"Timed out waiting for lock: {self.path}") from exc
|
|
43
|
+
time.sleep(0.05)
|
|
44
|
+
|
|
45
|
+
def release(self) -> None:
|
|
46
|
+
"""Release the lock if held."""
|
|
47
|
+
if not self._handle:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
if self._locked:
|
|
51
|
+
if sys.platform == "win32":
|
|
52
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
53
|
+
else:
|
|
54
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
|
|
55
|
+
finally:
|
|
56
|
+
self._handle.close()
|
|
57
|
+
self._handle = None
|
|
58
|
+
self._locked = False
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> FileLock:
|
|
61
|
+
self.acquire()
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *_args: object) -> None:
|
|
65
|
+
self.release()
|
claude_select/manager.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""ProfileManager implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from claude_select.exceptions import (
|
|
12
|
+
ConfigError,
|
|
13
|
+
ProfileNotFoundError,
|
|
14
|
+
ProfileReauthRequired,
|
|
15
|
+
ProfileValidationError,
|
|
16
|
+
)
|
|
17
|
+
from claude_select.live_state import ClaudeLiveStateBackend
|
|
18
|
+
from claude_select.models import (
|
|
19
|
+
AUTH_STATE_REFRESHABLE,
|
|
20
|
+
PROFILE_KIND_OAUTH,
|
|
21
|
+
LiveState,
|
|
22
|
+
ProfileMetadata,
|
|
23
|
+
SecretPayload,
|
|
24
|
+
utc_now_iso,
|
|
25
|
+
)
|
|
26
|
+
from claude_select.oauth import (
|
|
27
|
+
classify_auth_state,
|
|
28
|
+
mark_refresh_failure,
|
|
29
|
+
mark_refresh_success,
|
|
30
|
+
refresh_secret_payload,
|
|
31
|
+
update_profile_auth_metadata,
|
|
32
|
+
)
|
|
33
|
+
from claude_select.store import FileProfileStore
|
|
34
|
+
|
|
35
|
+
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
36
|
+
CONFLICTING_AUTH_ENV_VARS = {
|
|
37
|
+
"ANTHROPIC_API_KEY",
|
|
38
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
39
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
40
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
41
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
42
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
43
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
44
|
+
"CLAUDE_CODE_OAUTH_SCOPES",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProfileManager:
|
|
49
|
+
"""Main entry point for profile management."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
store: FileProfileStore | None = None,
|
|
54
|
+
live_state_backend: ClaudeLiveStateBackend | None = None,
|
|
55
|
+
refresh_request=None,
|
|
56
|
+
):
|
|
57
|
+
self.store = store or FileProfileStore()
|
|
58
|
+
self.live_state_backend = live_state_backend or ClaudeLiveStateBackend()
|
|
59
|
+
self.refresh_request = refresh_request
|
|
60
|
+
|
|
61
|
+
def list_profiles(self) -> list[dict[str, Any]]:
|
|
62
|
+
"""Return profile metadata dictionaries."""
|
|
63
|
+
profiles = []
|
|
64
|
+
for profile in self.store.list_profiles():
|
|
65
|
+
secret = self.store.get_secret(profile.secret_ref)
|
|
66
|
+
update_profile_auth_metadata(profile, secret)
|
|
67
|
+
self.store.update_profile(profile)
|
|
68
|
+
profiles.append(asdict(profile))
|
|
69
|
+
return profiles
|
|
70
|
+
|
|
71
|
+
def capture_cli_profile(self, name: str) -> dict[str, Any]:
|
|
72
|
+
"""Capture the current Claude CLI live state into a named profile."""
|
|
73
|
+
profile_name = self._validate_profile_name(name)
|
|
74
|
+
live_state = self.live_state_backend.read()
|
|
75
|
+
profile, secret = self._build_profile_from_live_state(profile_name, live_state)
|
|
76
|
+
self.store.upsert_profile(profile, secret)
|
|
77
|
+
self.store.set_current_cli_profile(profile_name)
|
|
78
|
+
if self.get_default_sdk_profile() is None:
|
|
79
|
+
self.store.set_default_sdk_profile(profile_name)
|
|
80
|
+
return asdict(profile)
|
|
81
|
+
|
|
82
|
+
def sync_cli_profile(self, name: str | None = None) -> dict[str, Any]:
|
|
83
|
+
"""Update a named profile using the current Claude CLI live state."""
|
|
84
|
+
state = self.store.load_state()
|
|
85
|
+
profile_name = name or state.current_cli_profile
|
|
86
|
+
if not profile_name:
|
|
87
|
+
raise ProfileValidationError(
|
|
88
|
+
"No profile name provided and no current CLI profile is set."
|
|
89
|
+
)
|
|
90
|
+
if profile_name not in state.profiles:
|
|
91
|
+
raise ProfileNotFoundError(f"Profile '{profile_name}' was not found.")
|
|
92
|
+
live_state = self.live_state_backend.read()
|
|
93
|
+
profile, secret = self._build_profile_from_live_state(profile_name, live_state)
|
|
94
|
+
self.store.upsert_profile(profile, secret)
|
|
95
|
+
return asdict(profile)
|
|
96
|
+
|
|
97
|
+
def switch_cli(self, name: str) -> dict[str, Any]:
|
|
98
|
+
"""Switch Claude's live state to a named profile."""
|
|
99
|
+
profile, secret = self._load_profile(name)
|
|
100
|
+
profile, secret, refresh_error = self._refresh_profile_if_needed(
|
|
101
|
+
profile,
|
|
102
|
+
secret,
|
|
103
|
+
allow_failure=True,
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
current_live_state = self.live_state_backend.read()
|
|
107
|
+
next_config = copy.deepcopy(current_live_state.config)
|
|
108
|
+
except ConfigError:
|
|
109
|
+
next_config = {}
|
|
110
|
+
next_config["oauthAccount"] = copy.deepcopy(secret.oauth_account)
|
|
111
|
+
live_state = LiveState(
|
|
112
|
+
config=next_config,
|
|
113
|
+
credentials=copy.deepcopy(secret.credentials),
|
|
114
|
+
)
|
|
115
|
+
self.live_state_backend.write(live_state)
|
|
116
|
+
profile.updated_at = utc_now_iso()
|
|
117
|
+
self.store.upsert_profile(profile, secret)
|
|
118
|
+
self.store.set_current_cli_profile(profile.id)
|
|
119
|
+
result = asdict(profile)
|
|
120
|
+
result["refresh_error"] = refresh_error
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def set_default_sdk_profile(self, name: str) -> None:
|
|
124
|
+
"""Set the default profile for SDK env generation."""
|
|
125
|
+
self._load_profile(name)
|
|
126
|
+
self.store.set_default_sdk_profile(name)
|
|
127
|
+
|
|
128
|
+
def get_default_sdk_profile(self) -> str | None:
|
|
129
|
+
"""Return the default SDK profile if configured."""
|
|
130
|
+
return self.store.load_state().default_sdk_profile
|
|
131
|
+
|
|
132
|
+
def get_current_cli_profile(self) -> str | None:
|
|
133
|
+
"""Return the currently selected CLI profile."""
|
|
134
|
+
return self.store.load_state().current_cli_profile
|
|
135
|
+
|
|
136
|
+
def inspect_profile(self, name: str) -> dict[str, Any]:
|
|
137
|
+
"""Return detailed metadata for one profile."""
|
|
138
|
+
profile, secret = self._load_profile(name)
|
|
139
|
+
update_profile_auth_metadata(profile, secret)
|
|
140
|
+
self.store.update_profile(profile)
|
|
141
|
+
details = asdict(profile)
|
|
142
|
+
details["scopes"] = self._get_scopes(secret)
|
|
143
|
+
return details
|
|
144
|
+
|
|
145
|
+
def remove_profile(self, name: str) -> None:
|
|
146
|
+
"""Remove a stored profile."""
|
|
147
|
+
self._load_profile(name)
|
|
148
|
+
self.store.remove_profile(name)
|
|
149
|
+
|
|
150
|
+
def build_sdk_env(
|
|
151
|
+
self,
|
|
152
|
+
name: str | None = None,
|
|
153
|
+
base_env: dict[str, str] | None = None,
|
|
154
|
+
) -> dict[str, str]:
|
|
155
|
+
"""Build a clean environment mapping for the requested profile."""
|
|
156
|
+
profile_name = name or self.get_default_sdk_profile()
|
|
157
|
+
if not profile_name:
|
|
158
|
+
raise ProfileValidationError(
|
|
159
|
+
"No profile specified and no default SDK profile is configured."
|
|
160
|
+
)
|
|
161
|
+
profile, secret = self._load_profile(profile_name)
|
|
162
|
+
profile, secret, _refresh_error = self._refresh_profile_if_needed(
|
|
163
|
+
profile,
|
|
164
|
+
secret,
|
|
165
|
+
allow_failure=False,
|
|
166
|
+
)
|
|
167
|
+
env = dict(base_env if base_env is not None else os.environ)
|
|
168
|
+
for key in CONFLICTING_AUTH_ENV_VARS:
|
|
169
|
+
env.pop(key, None)
|
|
170
|
+
oauth = secret.credentials["claudeAiOauth"]
|
|
171
|
+
env["CLAUDE_CODE_OAUTH_TOKEN"] = str(oauth["accessToken"])
|
|
172
|
+
refresh_token = oauth.get("refreshToken")
|
|
173
|
+
if refresh_token:
|
|
174
|
+
env["CLAUDE_CODE_OAUTH_REFRESH_TOKEN"] = str(refresh_token)
|
|
175
|
+
scopes = oauth.get("scopes")
|
|
176
|
+
if isinstance(scopes, list) and scopes:
|
|
177
|
+
env["CLAUDE_CODE_OAUTH_SCOPES"] = " ".join(str(scope) for scope in scopes)
|
|
178
|
+
return env
|
|
179
|
+
|
|
180
|
+
def _load_profile(self, name: str) -> tuple[ProfileMetadata, SecretPayload]:
|
|
181
|
+
state = self.store.load_state()
|
|
182
|
+
if name not in state.profiles:
|
|
183
|
+
raise ProfileNotFoundError(f"Profile '{name}' was not found.")
|
|
184
|
+
return state.profiles[name], self.store.get_secret(state.profiles[name].secret_ref)
|
|
185
|
+
|
|
186
|
+
def _validate_profile_name(self, name: str) -> str:
|
|
187
|
+
normalized = name.strip()
|
|
188
|
+
if not normalized:
|
|
189
|
+
raise ProfileValidationError("Profile name cannot be empty.")
|
|
190
|
+
if not PROFILE_NAME_RE.match(normalized):
|
|
191
|
+
raise ProfileValidationError(
|
|
192
|
+
"Profile name must only contain letters, numbers, dot, underscore, or dash."
|
|
193
|
+
)
|
|
194
|
+
return normalized
|
|
195
|
+
|
|
196
|
+
def _build_profile_from_live_state(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
live_state: LiveState,
|
|
200
|
+
) -> tuple[ProfileMetadata, SecretPayload]:
|
|
201
|
+
oauth_account = live_state.config.get("oauthAccount")
|
|
202
|
+
if not isinstance(oauth_account, dict):
|
|
203
|
+
raise ConfigError("Claude config does not contain oauthAccount.")
|
|
204
|
+
email = oauth_account.get("emailAddress")
|
|
205
|
+
if not email:
|
|
206
|
+
raise ConfigError("Claude config oauthAccount is missing emailAddress.")
|
|
207
|
+
secret = SecretPayload(
|
|
208
|
+
oauth_account=copy.deepcopy(oauth_account),
|
|
209
|
+
credentials=copy.deepcopy(live_state.credentials),
|
|
210
|
+
)
|
|
211
|
+
profile = ProfileMetadata(
|
|
212
|
+
id=name,
|
|
213
|
+
kind=PROFILE_KIND_OAUTH,
|
|
214
|
+
label=name,
|
|
215
|
+
email=str(email),
|
|
216
|
+
organization_id=str(oauth_account.get("organizationUuid", "") or ""),
|
|
217
|
+
organization_name=str(oauth_account.get("organizationName", "") or ""),
|
|
218
|
+
account_uuid=str(oauth_account.get("accountUuid", "") or ""),
|
|
219
|
+
secret_ref=name,
|
|
220
|
+
)
|
|
221
|
+
update_profile_auth_metadata(profile, secret)
|
|
222
|
+
return profile, secret
|
|
223
|
+
|
|
224
|
+
def _refresh_profile_if_needed(
|
|
225
|
+
self,
|
|
226
|
+
profile: ProfileMetadata,
|
|
227
|
+
secret: SecretPayload,
|
|
228
|
+
*,
|
|
229
|
+
allow_failure: bool,
|
|
230
|
+
) -> tuple[ProfileMetadata, SecretPayload, str | None]:
|
|
231
|
+
auth_state, _expires_at = classify_auth_state(secret)
|
|
232
|
+
if auth_state != AUTH_STATE_REFRESHABLE:
|
|
233
|
+
update_profile_auth_metadata(profile, secret)
|
|
234
|
+
self.store.update_profile(profile)
|
|
235
|
+
return profile, secret, None
|
|
236
|
+
try:
|
|
237
|
+
refreshed_secret = refresh_secret_payload(secret, request_refresh=self.refresh_request)
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
error = str(exc)
|
|
240
|
+
mark_refresh_failure(profile, error)
|
|
241
|
+
self.store.upsert_profile(profile, secret)
|
|
242
|
+
if allow_failure:
|
|
243
|
+
return profile, secret, error
|
|
244
|
+
raise ProfileReauthRequired(
|
|
245
|
+
f"Profile '{profile.id}' requires reauthentication: {error}"
|
|
246
|
+
) from exc
|
|
247
|
+
mark_refresh_success(profile, refreshed_secret)
|
|
248
|
+
self.store.upsert_profile(profile, refreshed_secret)
|
|
249
|
+
return profile, refreshed_secret, None
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _get_scopes(secret: SecretPayload) -> list[str]:
|
|
253
|
+
oauth = secret.credentials.get("claudeAiOauth", {})
|
|
254
|
+
scopes = oauth.get("scopes", [])
|
|
255
|
+
return [str(scope) for scope in scopes] if isinstance(scopes, list) else []
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_sdk_env(profile: str, base_env: dict[str, str] | None = None) -> dict[str, str]:
|
|
259
|
+
"""Convenience wrapper around ProfileManager.build_sdk_env."""
|
|
260
|
+
return ProfileManager().build_sdk_env(profile, base_env=base_env)
|