auto-auth-cli 0.0.1__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
auto_auth_cli/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+
12
+ from auto_auth_cli.metadata import AuthMetadata, sanitize_profile_key
13
+ from auto_auth_cli.paths import ToolPaths
14
+ from auto_auth_cli.store import ProfileStore
15
+ from auto_auth_cli.tools import get_tool, tool_names
16
+ from auto_auth_cli.tools.base import ToolAdapter
17
+
18
+
19
+ def main() -> None:
20
+ raise SystemExit(run(sys.argv[1:]))
21
+
22
+
23
+ def run(argv: list[str]) -> int:
24
+ parser = build_parser()
25
+ args = parser.parse_args(argv)
26
+ adapter = get_tool(args.tool)
27
+ paths = ToolPaths.from_env(adapter)
28
+ store = ProfileStore(paths)
29
+
30
+ try:
31
+ if args.status:
32
+ return _status(adapter, store, paths)
33
+ if args.setup:
34
+ return _setup(adapter, store, args.label)
35
+ if args.auto:
36
+ return _auto(adapter, store, args.tool_args)
37
+ if args.profile:
38
+ profile = store.install_profile(args.profile)
39
+ print(
40
+ f"Using {adapter.name} auth profile: {profile.metadata.label}",
41
+ file=sys.stderr,
42
+ )
43
+ return _exec_tool(adapter, args.tool_args)
44
+ except (OSError, ValueError, subprocess.CalledProcessError, json.JSONDecodeError) as error:
45
+ print(f"auto-auth: {error}", file=sys.stderr)
46
+ return 1
47
+
48
+ parser.error("provide --setup, --status, --auto, or --profile")
49
+ return 2
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ parser = argparse.ArgumentParser(prog="auto-auth")
54
+ subparsers = parser.add_subparsers(dest="tool", required=True)
55
+
56
+ for tool_name in tool_names():
57
+ tool_parser = subparsers.add_parser(
58
+ tool_name, help=f"manage and launch {tool_name} auth profiles"
59
+ )
60
+ group = tool_parser.add_mutually_exclusive_group(required=True)
61
+ group.add_argument("--setup", action="store_true", help=f"create a profile via {tool_name} login")
62
+ group.add_argument("--status", action="store_true", help=f"list {tool_name} auth profiles")
63
+ group.add_argument("--auto", action="store_true", help="use the first profile with available quota")
64
+ group.add_argument("--profile", help="profile email, key, account id, or unique prefix")
65
+ tool_parser.add_argument(
66
+ "--label",
67
+ help="fallback label for setup when the auth token has no email or account id",
68
+ )
69
+ tool_parser.add_argument("tool_args", nargs=argparse.REMAINDER)
70
+ return parser
71
+
72
+
73
+ def _status(adapter: ToolAdapter, store: ProfileStore, paths: ToolPaths) -> int:
74
+ active_metadata = None
75
+ if paths.active_auth_path.exists():
76
+ active_json = _read_json(paths.active_auth_path)
77
+ active_metadata = adapter.extract_metadata(active_json)
78
+
79
+ active_account = active_metadata.account_id if active_metadata else None
80
+ print(f"Active: {active_metadata.label if active_metadata else 'none'}")
81
+ print()
82
+ print("Profiles:")
83
+
84
+ profiles = store.list_profiles()
85
+ if not profiles:
86
+ print(" none")
87
+ return 0
88
+
89
+ for profile in profiles:
90
+ marker = (
91
+ " active"
92
+ if active_account and profile.metadata.account_id == active_account
93
+ else ""
94
+ )
95
+ plan = profile.metadata.plan_type or "unknown"
96
+ print(f" {profile.metadata.label}\t{plan}{marker}")
97
+ return 0
98
+
99
+
100
+ def _setup(adapter: ToolAdapter, store: ProfileStore, label: str | None) -> int:
101
+ executable = shutil.which(adapter.executable)
102
+ if executable is None:
103
+ raise OSError(f"{adapter.executable} executable not found in PATH")
104
+
105
+ with tempfile.TemporaryDirectory(prefix=f"auto-auth-{adapter.name}-") as temp_dir:
106
+ temp_home = Path(temp_dir)
107
+ subprocess.run(
108
+ adapter.login_command(executable),
109
+ check=True,
110
+ env=adapter.setup_env(temp_home),
111
+ )
112
+ auth_json = _read_json(adapter.active_auth_path(temp_home))
113
+ metadata = _metadata_with_label_fallback(adapter, auth_json, label)
114
+ if metadata.label == "unknown":
115
+ raise ValueError("could not extract email or account id; rerun with --label")
116
+ store.save_profile(metadata, auth_json)
117
+ print(f"Saved {adapter.name} auth profile: {metadata.label}")
118
+ return 0
119
+
120
+
121
+ def _auto(adapter: ToolAdapter, store: ProfileStore, tool_args: list[str]) -> int:
122
+ executable = shutil.which(adapter.executable)
123
+ if executable is None:
124
+ raise OSError(f"{adapter.executable} executable not found in PATH")
125
+
126
+ profiles = adapter.sort_profiles_for_auto(store.list_profiles())
127
+ if not profiles:
128
+ raise ValueError(f"no {adapter.name} auth profiles saved")
129
+
130
+ selector = getattr(adapter, "select_usable_profile", None)
131
+ if selector is None:
132
+ raise ValueError(f"{adapter.name} does not support automatic profile selection")
133
+
134
+ profile = selector(profiles, executable)
135
+ if profile is None:
136
+ raise ValueError(f"no usable {adapter.name} auth profiles found")
137
+
138
+ store.install_profile(profile.metadata.key)
139
+ print(
140
+ f"Using {adapter.name} auth profile: {profile.metadata.label}",
141
+ file=sys.stderr,
142
+ )
143
+ return _exec_tool(adapter, tool_args)
144
+
145
+
146
+ def _metadata_with_label_fallback(
147
+ adapter: ToolAdapter, auth_json: dict, label: str | None
148
+ ) -> AuthMetadata:
149
+ metadata = adapter.extract_metadata(auth_json)
150
+ if metadata.label != "unknown" or not label:
151
+ return metadata
152
+ return AuthMetadata(
153
+ key=sanitize_profile_key(label),
154
+ label=label,
155
+ email=None,
156
+ account_id=metadata.account_id,
157
+ plan_type=metadata.plan_type,
158
+ )
159
+
160
+
161
+ def _exec_tool(adapter: ToolAdapter, tool_args: list[str]) -> int:
162
+ args = tool_args[1:] if tool_args and tool_args[0] == "--" else tool_args
163
+ argv = [adapter.executable, *args]
164
+ try:
165
+ os.execvp(adapter.executable, argv)
166
+ except SystemExit as exc:
167
+ return int(exc.code or 0)
168
+ return 0
169
+
170
+
171
+ def _read_json(path: Path) -> dict:
172
+ data = json.loads(path.read_text())
173
+ if not isinstance(data, dict):
174
+ raise ValueError(f"{path} must contain a JSON object")
175
+ return data
auto_auth_cli/jwt.py ADDED
@@ -0,0 +1,19 @@
1
+ import base64
2
+ import json
3
+ from typing import Any
4
+
5
+
6
+ def decode_jwt_payload(token: str) -> dict[str, Any]:
7
+ parts = token.split(".")
8
+ if len(parts) < 2:
9
+ return {}
10
+
11
+ payload = parts[1]
12
+ padding = "=" * (-len(payload) % 4)
13
+ try:
14
+ decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
15
+ data = json.loads(decoded.decode("utf-8"))
16
+ except (ValueError, UnicodeDecodeError):
17
+ return {}
18
+
19
+ return data if isinstance(data, dict) else {}
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AuthMetadata:
9
+ key: str
10
+ label: str
11
+ email: str | None
12
+ account_id: str | None
13
+ plan_type: str | None
14
+
15
+
16
+ def sanitize_profile_key(value: str) -> str:
17
+ key = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_")
18
+ return key or "profile"
auto_auth_cli/paths.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+
9
+ class ToolPathAdapter(Protocol):
10
+ name: str
11
+
12
+ def default_auth_home(self) -> Path: ...
13
+
14
+ def active_auth_path(self, auth_home: Path) -> Path: ...
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ToolPaths:
19
+ tool_name: str
20
+ auto_auth_home: Path
21
+ auth_home: Path
22
+ active_auth_path: Path
23
+
24
+ @classmethod
25
+ def from_env(cls, adapter: ToolPathAdapter) -> ToolPaths:
26
+ home = Path.home()
27
+ auto_auth_root = Path(os.environ.get("AUTO_AUTH_HOME", home / ".auto-auth"))
28
+ auth_home = Path(
29
+ os.environ.get(f"{adapter.name.upper()}_HOME", adapter.default_auth_home())
30
+ )
31
+ return cls(
32
+ tool_name=adapter.name,
33
+ auto_auth_home=auto_auth_root / adapter.name,
34
+ auth_home=auth_home,
35
+ active_auth_path=adapter.active_auth_path(auth_home),
36
+ )
37
+
38
+ @property
39
+ def profiles_dir(self) -> Path:
40
+ return self.auto_auth_home / "profiles"
41
+
42
+ @property
43
+ def backups_dir(self) -> Path:
44
+ return self.auto_auth_home / "backups"
45
+
46
+
47
+ AutoAuthPaths = ToolPaths
auto_auth_cli/store.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+
10
+ from auto_auth_cli.metadata import AuthMetadata
11
+ from auto_auth_cli.paths import ToolPaths
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Profile:
16
+ metadata: AuthMetadata
17
+ auth_path: Path
18
+
19
+
20
+ class ProfileStore:
21
+ def __init__(self, paths: ToolPaths):
22
+ self.paths = paths
23
+
24
+ def save_profile(self, metadata: AuthMetadata, auth_json: dict) -> Profile:
25
+ profile_dir = self.paths.profiles_dir / metadata.key
26
+ profile_dir.mkdir(parents=True, exist_ok=True)
27
+ auth_path = profile_dir / "auth.json"
28
+ metadata_path = profile_dir / "metadata.json"
29
+ _write_json_atomic(auth_path, auth_json, mode=0o600)
30
+ _write_json_atomic(metadata_path, asdict(metadata), mode=0o600)
31
+ return Profile(metadata=metadata, auth_path=auth_path)
32
+
33
+ def list_profiles(self) -> list[Profile]:
34
+ if not self.paths.profiles_dir.exists():
35
+ return []
36
+
37
+ profiles: list[Profile] = []
38
+ for metadata_path in sorted(self.paths.profiles_dir.glob("*/metadata.json")):
39
+ try:
40
+ raw = json.loads(metadata_path.read_text())
41
+ metadata = AuthMetadata(**raw)
42
+ except (OSError, TypeError, ValueError):
43
+ continue
44
+ profiles.append(
45
+ Profile(metadata=metadata, auth_path=metadata_path.parent / "auth.json")
46
+ )
47
+ return profiles
48
+
49
+ def resolve_profile(self, selector: str) -> Profile:
50
+ selector_lower = selector.lower()
51
+ prefix_matches: list[Profile] = []
52
+
53
+ for profile in self.list_profiles():
54
+ candidates = [
55
+ profile.metadata.key,
56
+ profile.metadata.label,
57
+ profile.metadata.email or "",
58
+ profile.metadata.account_id or "",
59
+ ]
60
+ lowered = [candidate.lower() for candidate in candidates if candidate]
61
+ if selector_lower in lowered:
62
+ return profile
63
+ if any(candidate.startswith(selector_lower) for candidate in lowered):
64
+ prefix_matches.append(profile)
65
+
66
+ if len(prefix_matches) == 1:
67
+ return prefix_matches[0]
68
+ if len(prefix_matches) > 1:
69
+ labels = ", ".join(profile.metadata.label for profile in prefix_matches)
70
+ raise ValueError(f"profile selector {selector!r} is ambiguous: {labels}")
71
+ raise ValueError(f"profile {selector!r} not found")
72
+
73
+ def install_profile(self, selector: str) -> Profile:
74
+ profile = self.resolve_profile(selector)
75
+ self.paths.auth_home.mkdir(parents=True, exist_ok=True)
76
+ self.paths.backups_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ active_auth = self.paths.active_auth_path
79
+ if active_auth.exists():
80
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
81
+ backup_path = self.paths.backups_dir / f"auth.{timestamp}.json"
82
+ shutil.copy2(active_auth, backup_path)
83
+ _chmod_private(backup_path)
84
+
85
+ tmp_path = active_auth.with_name("auth.json.tmp")
86
+ shutil.copy2(profile.auth_path, tmp_path)
87
+ _chmod_private(tmp_path)
88
+ os.replace(tmp_path, active_auth)
89
+ _chmod_private(active_auth)
90
+ return profile
91
+
92
+
93
+ def _write_json_atomic(path: Path, data: dict, mode: int) -> None:
94
+ tmp_path = path.with_name(path.name + ".tmp")
95
+ tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
96
+ os.chmod(tmp_path, mode)
97
+ os.replace(tmp_path, path)
98
+
99
+
100
+ def _chmod_private(path: Path) -> None:
101
+ if os.name == "posix":
102
+ os.chmod(path, 0o600)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from auto_auth_cli.tools.base import ToolAdapter
4
+ from auto_auth_cli.tools.codex import CodexAdapter
5
+
6
+ _TOOLS: dict[str, ToolAdapter] = {
7
+ "codex": CodexAdapter(),
8
+ }
9
+
10
+
11
+ def get_tool(name: str) -> ToolAdapter:
12
+ try:
13
+ return _TOOLS[name]
14
+ except KeyError:
15
+ supported = ", ".join(sorted(_TOOLS))
16
+ raise ValueError(f"unsupported tool {name!r}; supported tools: {supported}") from None
17
+
18
+
19
+ def tool_names() -> list[str]:
20
+ return sorted(_TOOLS)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Protocol
5
+
6
+ from auto_auth_cli.metadata import AuthMetadata
7
+ from auto_auth_cli.store import Profile
8
+
9
+
10
+ class ToolAdapter(Protocol):
11
+ name: str
12
+ executable: str
13
+
14
+ def default_auth_home(self) -> Path: ...
15
+
16
+ def active_auth_path(self, auth_home: Path) -> Path: ...
17
+
18
+ def setup_env(self, temp_home: Path) -> dict[str, str]: ...
19
+
20
+ def login_command(self, executable_path: str) -> list[str]: ...
21
+
22
+ def extract_metadata(self, auth_json: dict[str, Any]) -> AuthMetadata: ...
23
+
24
+ def sort_profiles_for_auto(self, profiles: list[Profile]) -> list[Profile]: ...
@@ -0,0 +1,3 @@
1
+ from auto_auth_cli.tools.codex.adapter import CodexAdapter
2
+
3
+ __all__ = ["CodexAdapter"]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from auto_auth_cli.metadata import AuthMetadata
8
+ from auto_auth_cli.store import Profile
9
+ from auto_auth_cli.tools.codex.auth import extract_metadata
10
+ from auto_auth_cli.tools.codex.plans import plan_priority
11
+ from auto_auth_cli.tools.codex.rate_limits import profile_has_available_quota
12
+
13
+
14
+ class CodexAdapter:
15
+ name = "codex"
16
+ executable = "codex"
17
+
18
+ def default_auth_home(self) -> Path:
19
+ return Path.home() / ".codex"
20
+
21
+ def active_auth_path(self, auth_home: Path) -> Path:
22
+ return auth_home / "auth.json"
23
+
24
+ def setup_env(self, temp_home: Path) -> dict[str, str]:
25
+ env = os.environ.copy()
26
+ env["CODEX_HOME"] = str(temp_home)
27
+ return env
28
+
29
+ def login_command(self, executable_path: str) -> list[str]:
30
+ return [executable_path, "login"]
31
+
32
+ def extract_metadata(self, auth_json: dict[str, Any]) -> AuthMetadata:
33
+ return extract_metadata(auth_json)
34
+
35
+ def sort_profiles_for_auto(self, profiles: list[Profile]) -> list[Profile]:
36
+ return sorted(
37
+ profiles,
38
+ key=lambda profile: (
39
+ plan_priority(profile.metadata.plan_type),
40
+ profile.metadata.label.lower(),
41
+ ),
42
+ )
43
+
44
+ def select_usable_profile(
45
+ self, profiles: list[Profile], executable_path: str
46
+ ) -> Profile | None:
47
+ for profile in profiles:
48
+ try:
49
+ has_available_quota = profile_has_available_quota(
50
+ profile, executable_path
51
+ )
52
+ except (OSError, RuntimeError):
53
+ has_available_quota = False
54
+ if has_available_quota:
55
+ return profile
56
+ return None
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from auto_auth_cli.jwt import decode_jwt_payload
6
+ from auto_auth_cli.metadata import AuthMetadata, sanitize_profile_key
7
+
8
+ AUTH_CLAIMS_KEY = "https://api.openai.com/auth"
9
+
10
+
11
+ def extract_metadata(auth_json: dict[str, Any]) -> AuthMetadata:
12
+ tokens = auth_json.get("tokens")
13
+ if not isinstance(tokens, dict):
14
+ tokens = {}
15
+
16
+ payloads: list[dict[str, Any]] = []
17
+ for token_name in ("id_token", "access_token"):
18
+ token = tokens.get(token_name)
19
+ if isinstance(token, str):
20
+ payloads.append(decode_jwt_payload(token))
21
+
22
+ email = _first_string(payloads, "email")
23
+ account_id = _first_chatgpt_claim(payloads, "chatgpt_account_id")
24
+ plan_type = _first_chatgpt_claim(payloads, "chatgpt_plan_type")
25
+ label = email or account_id or "unknown"
26
+
27
+ return AuthMetadata(
28
+ key=sanitize_profile_key(label),
29
+ label=label,
30
+ email=email,
31
+ account_id=account_id,
32
+ plan_type=plan_type,
33
+ )
34
+
35
+
36
+ def _first_string(payloads: list[dict[str, Any]], key: str) -> str | None:
37
+ for payload in payloads:
38
+ value = payload.get(key)
39
+ if isinstance(value, str) and value.strip():
40
+ return value.strip()
41
+ return None
42
+
43
+
44
+ def _first_chatgpt_claim(payloads: list[dict[str, Any]], key: str) -> str | None:
45
+ for payload in payloads:
46
+ claims = payload.get(AUTH_CLAIMS_KEY)
47
+ if isinstance(claims, dict):
48
+ value = claims.get(key)
49
+ if isinstance(value, str) and value.strip():
50
+ return value.strip()
51
+ return None
@@ -0,0 +1,23 @@
1
+ AUTO_PLAN_PRIORITY = [
2
+ "free",
3
+ "go",
4
+ "plus",
5
+ "prolite",
6
+ "pro",
7
+ "team",
8
+ "self_serve_business_usage_based",
9
+ "business",
10
+ "enterprise_cbp_usage_based",
11
+ "enterprise",
12
+ "edu",
13
+ ]
14
+
15
+
16
+ def plan_priority(plan_type: str | None) -> int:
17
+ if plan_type is None:
18
+ return len(AUTO_PLAN_PRIORITY)
19
+ normalized = plan_type.strip().lower()
20
+ try:
21
+ return AUTO_PLAN_PRIORITY.index(normalized)
22
+ except ValueError:
23
+ return len(AUTO_PLAN_PRIORITY)
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import queue
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import threading
11
+ from typing import Any
12
+
13
+ from auto_auth_cli.store import Profile
14
+
15
+
16
+ def profile_has_available_quota(profile: Profile, executable_path: str) -> bool:
17
+ with tempfile.TemporaryDirectory(prefix="auto-auth-codex-probe-") as temp_dir:
18
+ temp_home = Path(temp_dir)
19
+ shutil.copy2(profile.auth_path, temp_home / "auth.json")
20
+ response = read_account_rate_limits(executable_path, temp_home)
21
+ return is_usable_rate_limits(response)
22
+
23
+
24
+ def is_usable_rate_limits(response: dict[str, Any]) -> bool:
25
+ rate_limits = response.get("rateLimits")
26
+ if not isinstance(rate_limits, dict):
27
+ return False
28
+ if rate_limits.get("rateLimitReachedType") is not None:
29
+ return False
30
+ return _window_allows(rate_limits.get("primary")) and _window_allows(
31
+ rate_limits.get("secondary")
32
+ )
33
+
34
+
35
+ def read_account_rate_limits(executable_path: str, codex_home: Path) -> dict[str, Any]:
36
+ env = os.environ.copy()
37
+ env["CODEX_HOME"] = str(codex_home)
38
+ process = subprocess.Popen(
39
+ [executable_path, "app-server", "--listen", "stdio://"],
40
+ stdin=subprocess.PIPE,
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.DEVNULL,
43
+ text=True,
44
+ env=env,
45
+ )
46
+ if process.stdin is None or process.stdout is None:
47
+ raise RuntimeError("failed to open codex app-server pipes")
48
+
49
+ lines: queue.Queue[str] = queue.Queue()
50
+ reader = threading.Thread(target=_read_stdout, args=(process.stdout, lines), daemon=True)
51
+ reader.start()
52
+
53
+ try:
54
+ _send(process, {"method": "initialize", "id": 1, "params": _initialize_params()})
55
+ _read_response(lines, request_id=1, timeout_seconds=10)
56
+ _send(process, {"method": "initialized", "params": {}})
57
+ _send(process, {"method": "account/rateLimits/read", "id": 2})
58
+ message = _read_response(lines, request_id=2, timeout_seconds=10)
59
+ finally:
60
+ process.terminate()
61
+ try:
62
+ process.wait(timeout=2)
63
+ except subprocess.TimeoutExpired:
64
+ process.kill()
65
+ process.wait(timeout=2)
66
+
67
+ if "error" in message:
68
+ error = message["error"]
69
+ if isinstance(error, dict):
70
+ raise RuntimeError(error.get("message") or json.dumps(error))
71
+ raise RuntimeError(str(error))
72
+
73
+ result = message.get("result")
74
+ if not isinstance(result, dict):
75
+ raise RuntimeError("codex app-server returned an invalid rate limit response")
76
+ return result
77
+
78
+
79
+ def _window_allows(value: Any) -> bool:
80
+ if value is None:
81
+ return True
82
+ if not isinstance(value, dict):
83
+ return False
84
+ used_percent = value.get("usedPercent")
85
+ if not isinstance(used_percent, int | float):
86
+ return False
87
+ return used_percent < 100
88
+
89
+
90
+ def _initialize_params() -> dict[str, Any]:
91
+ return {
92
+ "clientInfo": {
93
+ "name": "auto_auth_cli",
94
+ "title": "auto-auth-cli",
95
+ "version": "0.1.0",
96
+ }
97
+ }
98
+
99
+
100
+ def _send(process: subprocess.Popen[str], message: dict[str, Any]) -> None:
101
+ if process.stdin is None:
102
+ raise RuntimeError("codex app-server stdin is closed")
103
+ process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
104
+ process.stdin.flush()
105
+
106
+
107
+ def _read_stdout(stdout, lines: queue.Queue[str]) -> None:
108
+ for line in stdout:
109
+ lines.put(line)
110
+
111
+
112
+ def _read_response(
113
+ lines: queue.Queue[str], request_id: int, timeout_seconds: int
114
+ ) -> dict[str, Any]:
115
+ while True:
116
+ try:
117
+ line = lines.get(timeout=timeout_seconds)
118
+ except queue.Empty as exc:
119
+ raise RuntimeError("timed out waiting for codex rate limits") from exc
120
+ try:
121
+ message = json.loads(line)
122
+ except json.JSONDecodeError:
123
+ continue
124
+ if not isinstance(message, dict):
125
+ continue
126
+ if message.get("id") == request_id:
127
+ return message
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.3
2
+ Name: auto-auth-cli
3
+ Version: 0.0.1
4
+ Summary: Profile-aware auth wrapper for coder agent CLIs
5
+ Keywords: ai,auth,cli,codex,profile
6
+ Author: midodimori
7
+ Author-email: midodimori <midodimori@proton.me>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Software Development
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.13
21
+ Project-URL: Homepage, https://github.com/midodimori/auto-auth-cli
22
+ Project-URL: Repository, https://github.com/midodimori/auto-auth-cli
23
+ Project-URL: Issues, https://github.com/midodimori/auto-auth-cli/issues
24
+ Project-URL: Changelog, https://github.com/midodimori/auto-auth-cli/blob/main/CHANGELOG.md
25
+ Description-Content-Type: text/markdown
26
+
27
+ # auto-auth-cli
28
+
29
+ Profile-aware auth switching for coder agent CLIs.
30
+
31
+ [![PyPI - Version](https://img.shields.io/pypi/v/auto-auth-cli?logo=pypi&logoColor=white)](https://pypi.org/project/auto-auth-cli/)
32
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/auto-auth-cli?logo=pypi&logoColor=white)](https://pypi.org/project/auto-auth-cli/)
33
+ [![Python Version](https://img.shields.io/pypi/pyversions/auto-auth-cli?logo=python&logoColor=white)](https://pypi.org/project/auto-auth-cli/)
34
+ [![License](https://img.shields.io/github/license/midodimori/auto-auth-cli)](https://github.com/midodimori/auto-auth-cli/blob/main/LICENSE)
35
+
36
+ `auto-auth` lets you keep multiple authentication profiles for a supported CLI and launch that CLI with the profile you choose. For Codex, it only replaces:
37
+
38
+ ```text
39
+ ~/.codex/auth.json
40
+ ```
41
+
42
+ Everything else in `~/.codex` stays shared across profiles.
43
+
44
+ ## Table of Contents
45
+
46
+ - [Supported Tools](#supported-tools)
47
+ - [Prerequisites](#prerequisites)
48
+ - [Installation](#installation)
49
+ - [From PyPI](#from-pypi)
50
+ - [Quick Try from GitHub](#quick-try-from-github)
51
+ - [Install from GitHub](#install-from-github)
52
+ - [From Source](#from-source)
53
+ - [Quick Start](#quick-start)
54
+ - [Usage](#usage)
55
+ - [Codex](#codex)
56
+ - [Claude Code](#claude-code)
57
+ - [Stored Files](#stored-files)
58
+ - [Development](#development)
59
+
60
+ ## Supported Tools
61
+
62
+ | Tool | Status | Command category |
63
+ |------|--------|------------------|
64
+ | Codex | Supported | `auto-auth codex ...` |
65
+ | Claude Code | Planned | Not available yet |
66
+
67
+ ## Prerequisites
68
+
69
+ - **Python 3.13+** - Required by the package.
70
+ - **[uv](https://docs.astral.sh/uv/)** - Used for installation and local development.
71
+ - **Codex CLI** - Required for the current `codex` category. The `codex` executable must be available in `PATH`.
72
+
73
+ ## Installation
74
+
75
+ Use `--python 3.13` so uv builds the isolated tool environment with a supported Python version.
76
+
77
+ ### From PyPI
78
+
79
+ Use this for the latest published release.
80
+
81
+ **Quick try (no installation):**
82
+
83
+ ```sh
84
+ uvx --python 3.13 --from auto-auth-cli@latest auto-auth codex --status
85
+ ```
86
+
87
+ **Install globally:**
88
+
89
+ ```sh
90
+ uv tool install --python 3.13 auto-auth-cli
91
+ ```
92
+
93
+ Then run from any directory:
94
+
95
+ ```sh
96
+ auto-auth codex --status
97
+ ```
98
+
99
+ > **Upgrading:** Run `uv tool upgrade --python 3.13 auto-auth-cli`.
100
+
101
+ ### Quick Try from GitHub
102
+
103
+ Run `auto-auth` directly from GitHub without installing it globally:
104
+
105
+ ```sh
106
+ uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --status
107
+ ```
108
+
109
+ You can use the same `uvx` form for any Codex command:
110
+
111
+ ```sh
112
+ uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --setup
113
+ uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --auto
114
+ uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --profile you -- -m gpt-5.5
115
+ ```
116
+
117
+ ### Install from GitHub
118
+
119
+ Recommended when you want the latest version from the repository available as a normal command.
120
+
121
+ ```sh
122
+ uv tool install --python 3.13 git+https://github.com/midodimori/auto-auth-cli
123
+ ```
124
+
125
+ Then run from any directory:
126
+
127
+ ```sh
128
+ auto-auth codex --status
129
+ ```
130
+
131
+ > **Upgrading:** Re-run `uv tool install --force --python 3.13 git+https://github.com/midodimori/auto-auth-cli`.
132
+
133
+ ### From Source
134
+
135
+ ```sh
136
+ git clone https://github.com/midodimori/auto-auth-cli.git
137
+ cd auto-auth-cli
138
+ uv sync
139
+ uv run auto-auth codex --status
140
+ ```
141
+
142
+ To install globally from source:
143
+
144
+ ```sh
145
+ uv tool install --python 3.13 --editable .
146
+ ```
147
+
148
+ Then run from any directory:
149
+
150
+ ```sh
151
+ auto-auth codex --status
152
+ ```
153
+
154
+ ## Quick Start
155
+
156
+ Create a Codex profile:
157
+
158
+ ```sh
159
+ auto-auth codex --setup
160
+ ```
161
+
162
+ List saved profiles:
163
+
164
+ ```sh
165
+ auto-auth codex --status
166
+ ```
167
+
168
+ Run Codex with a saved profile:
169
+
170
+ ```sh
171
+ auto-auth codex --profile you@example.com
172
+ ```
173
+
174
+ Run Codex with the first profile that has available quota:
175
+
176
+ ```sh
177
+ auto-auth codex --auto
178
+ ```
179
+
180
+ Pass Codex arguments after `--`:
181
+
182
+ ```sh
183
+ auto-auth codex --profile you -- -m gpt-5.5 # Run Codex with a specific profile and model
184
+ auto-auth codex --auto -- -m gpt-5.5 # Auto-select a profile, then run Codex with a model
185
+ auto-auth codex --auto -- --yolo app # Auto-select a profile, then run the Codex app in yolo mode
186
+ ```
187
+
188
+ ## Usage
189
+
190
+ The first positional argument is the tool category. Codex is the only supported category today.
191
+
192
+ ```sh
193
+ auto-auth <tool> [OPTIONS] [-- TOOL_ARGS...]
194
+ ```
195
+
196
+ ### Codex
197
+
198
+ #### Create a Profile
199
+
200
+ ```sh
201
+ auto-auth codex --setup
202
+ ```
203
+
204
+ `--setup` runs `codex login` in a temporary Codex home, extracts the account metadata from the generated auth file, and saves it as a reusable profile. It does not overwrite your active `~/.codex/auth.json`.
205
+
206
+ If setup cannot detect an email or account id, provide a label:
207
+
208
+ ```sh
209
+ auto-auth codex --setup --label work
210
+ ```
211
+
212
+ #### List Profiles
213
+
214
+ ```sh
215
+ auto-auth codex --status
216
+ ```
217
+
218
+ The status output shows the active profile, saved profiles, detected plan type, and active marker when the saved account matches the current `~/.codex/auth.json`.
219
+
220
+ #### Select a Profile
221
+
222
+ ```sh
223
+ auto-auth codex --profile you@example.com
224
+ ```
225
+
226
+ `--profile` accepts a profile email, profile key, account id, or unique prefix:
227
+
228
+ ```sh
229
+ auto-auth codex --profile you
230
+ ```
231
+
232
+ Before switching, `auto-auth` backs up the current Codex auth file, then installs the selected profile into:
233
+
234
+ ```text
235
+ ~/.codex/auth.json
236
+ ```
237
+
238
+ #### Auto-Select a Profile
239
+
240
+ ```sh
241
+ auto-auth codex --auto
242
+ ```
243
+
244
+ `--auto` checks saved profiles and launches Codex with the first account that has available quota. Smaller subscriptions are checked first.
245
+
246
+ #### Pass Codex Arguments
247
+
248
+ Put Codex arguments after `--`:
249
+
250
+ ```sh
251
+ auto-auth codex --profile you -- -m gpt-5.5 # Run Codex with a specific profile and model
252
+ auto-auth codex --auto -- -m gpt-5.5 # Auto-select a profile, then run Codex with a model
253
+ auto-auth codex --auto -- --yolo app # Auto-select a profile, then run the Codex app in yolo mode
254
+ ```
255
+
256
+ The last example runs the Codex app in yolo mode after selecting an available profile.
257
+
258
+ #### Codex Environment Variables
259
+
260
+ | Variable | Description | Default |
261
+ |----------|-------------|---------|
262
+ | `CODEX_HOME` | Active Codex config directory | `~/.codex` |
263
+ | `AUTO_AUTH_HOME` | Root directory for saved profiles and backups | `~/.auto-auth` |
264
+
265
+ ### Claude Code
266
+
267
+ Claude Code support is planned for a future tool category. Until that adapter exists, `auto-auth` only accepts:
268
+
269
+ ```sh
270
+ auto-auth codex ...
271
+ ```
272
+
273
+ ## Stored Files
274
+
275
+ Codex profiles:
276
+
277
+ ```text
278
+ ~/.auto-auth/codex/profiles/
279
+ ```
280
+
281
+ Codex auth backups:
282
+
283
+ ```text
284
+ ~/.auto-auth/codex/backups/
285
+ ```
286
+
287
+ If `AUTO_AUTH_HOME` is set, those paths move under that directory.
288
+
289
+ ## Development
290
+
291
+ ```sh
292
+ uv sync --all-groups
293
+ uv run pre-commit install
294
+ uv run pre-commit run --all-files
295
+ uv run pytest -q
296
+ uv run ruff check .
297
+ uv run ty check
298
+ ```
@@ -0,0 +1,17 @@
1
+ auto_auth_cli/__init__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
2
+ auto_auth_cli/cli.py,sha256=HHHUqJYD2WpbdHIXII20DQcDzsMVzM-37n9hhVGEwxw,6177
3
+ auto_auth_cli/jwt.py,sha256=OBpe5KUofOZPIT7TUYUGsf0oc-6nx6bJ8o0GFTyEvYs,493
4
+ auto_auth_cli/metadata.py,sha256=ov2Dekn60uzcchwiaLiHOGuyK8t0CWY0Y0pQYA04AkY,378
5
+ auto_auth_cli/paths.py,sha256=UCM-uyTD9nYWKxMYzGunJ92rLdnrtEfdyHgTwyBVAZU,1187
6
+ auto_auth_cli/store.py,sha256=A-8jnn7MjeRZ6WOEffffIW74UtFzIK5YXp3H33GGPT4,3737
7
+ auto_auth_cli/tools/__init__.py,sha256=-YQOQeR2fFwpczHIjB1RTtU1x8fiCnntTfSrlFaAx8A,503
8
+ auto_auth_cli/tools/base.py,sha256=mae-p1SxDhv6PpQ3sLZ03AqODAxMCYJHlQV1n2yyQb0,655
9
+ auto_auth_cli/tools/codex/__init__.py,sha256=-J9j-UrC-NJ0dQkjPxxFpuKyZ81NWvr6iZS5reTWoV8,87
10
+ auto_auth_cli/tools/codex/adapter.py,sha256=hqf8pH22IVjUatkOBlDy4Zvl7lXaMVapLICwixm6oNo,1767
11
+ auto_auth_cli/tools/codex/auth.py,sha256=ZElq4kBY74m8LNYJ5P9ClINgABMvqdry5IK0fu8_8TM,1598
12
+ auto_auth_cli/tools/codex/plans.py,sha256=yeK1ZdVzzfLHCOQvlQRoa66CTTRO865Mxtf9Fp6PF5s,497
13
+ auto_auth_cli/tools/codex/rate_limits.py,sha256=u15DEQLFphAHVjODgB6i4w6j9QcPvv7k4AULySbDT2M,4036
14
+ auto_auth_cli-0.0.1.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
15
+ auto_auth_cli-0.0.1.dist-info/entry_points.txt,sha256=4bSJJNx9SrZ1fnYX7K9Kc5uyWgGyyEI-SktvEON8iwk,54
16
+ auto_auth_cli-0.0.1.dist-info/METADATA,sha256=ijYdyMxC23L7F83kM_oQHUpt7_mmLY_pa1eN6EMM_0k,7829
17
+ auto_auth_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ auto-auth = auto_auth_cli.cli:main
3
+