auto-auth-cli 0.0.1__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.
- auto_auth_cli-0.0.1/PKG-INFO +298 -0
- auto_auth_cli-0.0.1/README.md +272 -0
- auto_auth_cli-0.0.1/pyproject.toml +79 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/__init__.py +1 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/cli.py +175 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/jwt.py +19 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/metadata.py +18 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/paths.py +47 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/store.py +102 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/__init__.py +20 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/base.py +24 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/codex/__init__.py +3 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/codex/adapter.py +56 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/codex/auth.py +51 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/codex/plans.py +23 -0
- auto_auth_cli-0.0.1/src/auto_auth_cli/tools/codex/rate_limits.py +127 -0
|
@@ -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
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
32
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
33
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
34
|
+
[](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,272 @@
|
|
|
1
|
+
# auto-auth-cli
|
|
2
|
+
|
|
3
|
+
Profile-aware auth switching for coder agent CLIs.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
6
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
7
|
+
[](https://pypi.org/project/auto-auth-cli/)
|
|
8
|
+
[](https://github.com/midodimori/auto-auth-cli/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
`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:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
~/.codex/auth.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Everything else in `~/.codex` stays shared across profiles.
|
|
17
|
+
|
|
18
|
+
## Table of Contents
|
|
19
|
+
|
|
20
|
+
- [Supported Tools](#supported-tools)
|
|
21
|
+
- [Prerequisites](#prerequisites)
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [From PyPI](#from-pypi)
|
|
24
|
+
- [Quick Try from GitHub](#quick-try-from-github)
|
|
25
|
+
- [Install from GitHub](#install-from-github)
|
|
26
|
+
- [From Source](#from-source)
|
|
27
|
+
- [Quick Start](#quick-start)
|
|
28
|
+
- [Usage](#usage)
|
|
29
|
+
- [Codex](#codex)
|
|
30
|
+
- [Claude Code](#claude-code)
|
|
31
|
+
- [Stored Files](#stored-files)
|
|
32
|
+
- [Development](#development)
|
|
33
|
+
|
|
34
|
+
## Supported Tools
|
|
35
|
+
|
|
36
|
+
| Tool | Status | Command category |
|
|
37
|
+
|------|--------|------------------|
|
|
38
|
+
| Codex | Supported | `auto-auth codex ...` |
|
|
39
|
+
| Claude Code | Planned | Not available yet |
|
|
40
|
+
|
|
41
|
+
## Prerequisites
|
|
42
|
+
|
|
43
|
+
- **Python 3.13+** - Required by the package.
|
|
44
|
+
- **[uv](https://docs.astral.sh/uv/)** - Used for installation and local development.
|
|
45
|
+
- **Codex CLI** - Required for the current `codex` category. The `codex` executable must be available in `PATH`.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Use `--python 3.13` so uv builds the isolated tool environment with a supported Python version.
|
|
50
|
+
|
|
51
|
+
### From PyPI
|
|
52
|
+
|
|
53
|
+
Use this for the latest published release.
|
|
54
|
+
|
|
55
|
+
**Quick try (no installation):**
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uvx --python 3.13 --from auto-auth-cli@latest auto-auth codex --status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Install globally:**
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
uv tool install --python 3.13 auto-auth-cli
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then run from any directory:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
auto-auth codex --status
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> **Upgrading:** Run `uv tool upgrade --python 3.13 auto-auth-cli`.
|
|
74
|
+
|
|
75
|
+
### Quick Try from GitHub
|
|
76
|
+
|
|
77
|
+
Run `auto-auth` directly from GitHub without installing it globally:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --status
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can use the same `uvx` form for any Codex command:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --setup
|
|
87
|
+
uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --auto
|
|
88
|
+
uvx --python 3.13 --from git+https://github.com/midodimori/auto-auth-cli auto-auth codex --profile you -- -m gpt-5.5
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Install from GitHub
|
|
92
|
+
|
|
93
|
+
Recommended when you want the latest version from the repository available as a normal command.
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
uv tool install --python 3.13 git+https://github.com/midodimori/auto-auth-cli
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then run from any directory:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
auto-auth codex --status
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> **Upgrading:** Re-run `uv tool install --force --python 3.13 git+https://github.com/midodimori/auto-auth-cli`.
|
|
106
|
+
|
|
107
|
+
### From Source
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
git clone https://github.com/midodimori/auto-auth-cli.git
|
|
111
|
+
cd auto-auth-cli
|
|
112
|
+
uv sync
|
|
113
|
+
uv run auto-auth codex --status
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
To install globally from source:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
uv tool install --python 3.13 --editable .
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Then run from any directory:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
auto-auth codex --status
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Quick Start
|
|
129
|
+
|
|
130
|
+
Create a Codex profile:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
auto-auth codex --setup
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
List saved profiles:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
auto-auth codex --status
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Run Codex with a saved profile:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
auto-auth codex --profile you@example.com
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Run Codex with the first profile that has available quota:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
auto-auth codex --auto
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Pass Codex arguments after `--`:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
auto-auth codex --profile you -- -m gpt-5.5 # Run Codex with a specific profile and model
|
|
158
|
+
auto-auth codex --auto -- -m gpt-5.5 # Auto-select a profile, then run Codex with a model
|
|
159
|
+
auto-auth codex --auto -- --yolo app # Auto-select a profile, then run the Codex app in yolo mode
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Usage
|
|
163
|
+
|
|
164
|
+
The first positional argument is the tool category. Codex is the only supported category today.
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
auto-auth <tool> [OPTIONS] [-- TOOL_ARGS...]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Codex
|
|
171
|
+
|
|
172
|
+
#### Create a Profile
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
auto-auth codex --setup
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`--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`.
|
|
179
|
+
|
|
180
|
+
If setup cannot detect an email or account id, provide a label:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
auto-auth codex --setup --label work
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### List Profiles
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
auto-auth codex --status
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
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`.
|
|
193
|
+
|
|
194
|
+
#### Select a Profile
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
auto-auth codex --profile you@example.com
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`--profile` accepts a profile email, profile key, account id, or unique prefix:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
auto-auth codex --profile you
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Before switching, `auto-auth` backs up the current Codex auth file, then installs the selected profile into:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
~/.codex/auth.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### Auto-Select a Profile
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
auto-auth codex --auto
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`--auto` checks saved profiles and launches Codex with the first account that has available quota. Smaller subscriptions are checked first.
|
|
219
|
+
|
|
220
|
+
#### Pass Codex Arguments
|
|
221
|
+
|
|
222
|
+
Put Codex arguments after `--`:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
auto-auth codex --profile you -- -m gpt-5.5 # Run Codex with a specific profile and model
|
|
226
|
+
auto-auth codex --auto -- -m gpt-5.5 # Auto-select a profile, then run Codex with a model
|
|
227
|
+
auto-auth codex --auto -- --yolo app # Auto-select a profile, then run the Codex app in yolo mode
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The last example runs the Codex app in yolo mode after selecting an available profile.
|
|
231
|
+
|
|
232
|
+
#### Codex Environment Variables
|
|
233
|
+
|
|
234
|
+
| Variable | Description | Default |
|
|
235
|
+
|----------|-------------|---------|
|
|
236
|
+
| `CODEX_HOME` | Active Codex config directory | `~/.codex` |
|
|
237
|
+
| `AUTO_AUTH_HOME` | Root directory for saved profiles and backups | `~/.auto-auth` |
|
|
238
|
+
|
|
239
|
+
### Claude Code
|
|
240
|
+
|
|
241
|
+
Claude Code support is planned for a future tool category. Until that adapter exists, `auto-auth` only accepts:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
auto-auth codex ...
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Stored Files
|
|
248
|
+
|
|
249
|
+
Codex profiles:
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
~/.auto-auth/codex/profiles/
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Codex auth backups:
|
|
256
|
+
|
|
257
|
+
```text
|
|
258
|
+
~/.auto-auth/codex/backups/
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
If `AUTO_AUTH_HOME` is set, those paths move under that directory.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```sh
|
|
266
|
+
uv sync --all-groups
|
|
267
|
+
uv run pre-commit install
|
|
268
|
+
uv run pre-commit run --all-files
|
|
269
|
+
uv run pytest -q
|
|
270
|
+
uv run ruff check .
|
|
271
|
+
uv run ty check
|
|
272
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "auto-auth-cli"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Profile-aware auth wrapper for coder agent CLIs"
|
|
5
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "midodimori", email = "midodimori@proton.me" }
|
|
8
|
+
]
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
keywords = [
|
|
12
|
+
"ai",
|
|
13
|
+
"auth",
|
|
14
|
+
"cli",
|
|
15
|
+
"codex",
|
|
16
|
+
"profile",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: MacOS",
|
|
24
|
+
"Operating System :: POSIX :: Linux",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Topic :: Software Development",
|
|
29
|
+
"Topic :: System :: Systems Administration",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/midodimori/auto-auth-cli"
|
|
35
|
+
Repository = "https://github.com/midodimori/auto-auth-cli"
|
|
36
|
+
Issues = "https://github.com/midodimori/auto-auth-cli/issues"
|
|
37
|
+
Changelog = "https://github.com/midodimori/auto-auth-cli/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
auto-auth = "auto_auth_cli.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.9.14,<0.10.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"pre-commit>=4.6.0",
|
|
49
|
+
"pytest>=9.0.3",
|
|
50
|
+
"python-semantic-release~=10.5.3",
|
|
51
|
+
"ruff>=0.15.16",
|
|
52
|
+
"ty>=0.0.44",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.semantic_release]
|
|
56
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
57
|
+
version_variables = ["src/auto_auth_cli/__init__.py:__version__"]
|
|
58
|
+
commit_parser = "conventional"
|
|
59
|
+
major_on_zero = false
|
|
60
|
+
commit_author = "github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
|
61
|
+
build_command = "uv lock && git add uv.lock"
|
|
62
|
+
|
|
63
|
+
[tool.semantic_release.commit_parser_options]
|
|
64
|
+
allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "test", "build", "ci", "chore"]
|
|
65
|
+
minor_tags = ["feat"]
|
|
66
|
+
patch_tags = ["fix", "perf", "refactor"]
|
|
67
|
+
|
|
68
|
+
[tool.semantic_release.changelog]
|
|
69
|
+
exclude_commit_patterns = []
|
|
70
|
+
mode = "update"
|
|
71
|
+
|
|
72
|
+
[tool.semantic_release.changelog.default_templates]
|
|
73
|
+
changelog_file = "CHANGELOG.md"
|
|
74
|
+
|
|
75
|
+
[tool.semantic_release.changelog.environment]
|
|
76
|
+
keep_trailing_newline = true
|
|
77
|
+
|
|
78
|
+
[tool.semantic_release.remote]
|
|
79
|
+
type = "github"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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,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
|