codex-accounts 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.
- codex_accounts-0.1.0.dist-info/METADATA +238 -0
- codex_accounts-0.1.0.dist-info/RECORD +13 -0
- codex_accounts-0.1.0.dist-info/WHEEL +4 -0
- codex_accounts-0.1.0.dist-info/entry_points.txt +2 -0
- codex_accounts-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_switch/__init__.py +6 -0
- codex_switch/__main__.py +8 -0
- codex_switch/accounts.py +265 -0
- codex_switch/api.py +144 -0
- codex_switch/cli.py +532 -0
- codex_switch/config.py +96 -0
- codex_switch/stats.py +139 -0
- codex_switch/utils.py +302 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-accounts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-account CLI manager for the OpenAI Codex desktop app
|
|
5
|
+
Project-URL: Homepage, https://github.com/wikty/codex-accounts
|
|
6
|
+
Project-URL: Documentation, https://github.com/wikty/codex-accounts#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/wikty/codex-accounts/issues
|
|
8
|
+
Project-URL: Source, https://github.com/wikty/codex-accounts
|
|
9
|
+
Project-URL: Changelog, https://github.com/wikty/codex-accounts/blob/main/CHANGELOG.md
|
|
10
|
+
Author: codex-accounts contributors
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2026 wikty and codex-switch contributors
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Keywords: account-switcher,chatgpt,cli,codex,multi-account,openai
|
|
34
|
+
Classifier: Development Status :: 4 - Beta
|
|
35
|
+
Classifier: Environment :: Console
|
|
36
|
+
Classifier: Intended Audience :: Developers
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: MacOS
|
|
39
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
40
|
+
Classifier: Programming Language :: Python :: 3
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
45
|
+
Classifier: Topic :: Utilities
|
|
46
|
+
Requires-Python: >=3.9
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
<!-- markdownlint-disable MD041 -->
|
|
55
|
+
<h1 align="center">codex-accounts</h1>
|
|
56
|
+
|
|
57
|
+
<p align="center">
|
|
58
|
+
<em>A multi-account CLI manager for the OpenAI Codex desktop app.</em>
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<p align="center">
|
|
62
|
+
<a href="https://github.com/wikty/codex-accounts/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/wikty/codex-accounts/ci.yml?branch=main&label=tests"></a>
|
|
63
|
+
<a href="https://pypi.org/project/codex-accounts/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codex-accounts.svg"></a>
|
|
64
|
+
<a href="https://pypi.org/project/codex-accounts/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/codex-accounts.svg"></a>
|
|
65
|
+
<a href="./LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
<p align="center">
|
|
69
|
+
<a href="./README.zh-CN.md">中文文档</a> · <a href="./docs/installation.md">Install</a> · <a href="./docs/add-account.md">Add an account</a> · <a href="./docs/faq.md">FAQ</a>
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
The OpenAI Codex desktop app stores all sessions, logs, and UI state in
|
|
75
|
+
`~/.codex/` — and the underlying SQLite database has **no notion of which
|
|
76
|
+
account a row belongs to**. The moment you switch to a different ChatGPT
|
|
77
|
+
account, every previous session disappears from the sidebar.
|
|
78
|
+
|
|
79
|
+
**codex-accounts** fixes this by swapping the relevant files in and out of
|
|
80
|
+
`~/.codex/`, giving you clean multi-account isolation with a single command:
|
|
81
|
+
|
|
82
|
+
```console
|
|
83
|
+
$ codex-accounts use work
|
|
84
|
+
✓ switched to: work (auth mode)
|
|
85
|
+
|
|
86
|
+
$ codex-accounts list
|
|
87
|
+
Accounts (/Users/you/.codex-accounts):
|
|
88
|
+
|
|
89
|
+
▶ work work@example.com Plus 5d left 2h ago ← current
|
|
90
|
+
personal personal@me.io Plus 9d left yesterday
|
|
91
|
+
research lab@university.edu Pro 1d left ⚠ 3d ago
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Features
|
|
95
|
+
|
|
96
|
+
- **One-command switching** — `codex-accounts use <name>` saves the current
|
|
97
|
+
account and activates the target in a single step.
|
|
98
|
+
- **Two isolation modes** — share session history across all accounts
|
|
99
|
+
(default) or fully isolate sessions, history, and shell snapshots per
|
|
100
|
+
account (`--no-share-sessions`).
|
|
101
|
+
- **Live quota** — `quota` and `list -a` query the same private endpoint
|
|
102
|
+
the Codex client uses for its 5h/weekly progress bars.
|
|
103
|
+
- **Local usage stats** — `stats` reads the Codex SQLite database directly
|
|
104
|
+
for offline token-usage breakdowns.
|
|
105
|
+
- **Auto-refresh** — `refresh` opens the 5-hour window across every
|
|
106
|
+
account so a sliding daily allotment never goes unused (see
|
|
107
|
+
[docs/auto-refresh.md](./docs/auto-refresh.md)).
|
|
108
|
+
- **Zero dependencies** — pure stdlib Python; ships as a single console
|
|
109
|
+
script.
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
| | Supported |
|
|
114
|
+
|---|---|
|
|
115
|
+
| **OS** | macOS 12+ (primary), Linux (best-effort, PRs welcome) |
|
|
116
|
+
| **Python** | 3.9+ |
|
|
117
|
+
| **Codex desktop app** | already installed and signed in at least once |
|
|
118
|
+
|
|
119
|
+
> Windows is not currently supported. The path conventions and process
|
|
120
|
+
> detection in `codex_switch.utils` would need to be extended; PRs welcome.
|
|
121
|
+
|
|
122
|
+
## Install
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Recommended: isolated install with pipx
|
|
126
|
+
pipx install codex-accounts
|
|
127
|
+
|
|
128
|
+
# Or with pip
|
|
129
|
+
pip install --user codex-accounts
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For a no-package-manager install of the bundled launcher script, see
|
|
133
|
+
[`docs/installation.md`](./docs/installation.md).
|
|
134
|
+
|
|
135
|
+
## Quick start
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 1. Snapshot the account you're already logged in to
|
|
139
|
+
codex-accounts save work
|
|
140
|
+
|
|
141
|
+
# 2. Add a second account (interactive)
|
|
142
|
+
codex-accounts init personal # placeholder dir
|
|
143
|
+
codex-accounts use personal # clears auth.json so Codex prompts you to log in
|
|
144
|
+
open -a Codex # finish OAuth in the browser
|
|
145
|
+
codex-accounts save personal # capture the new account's data
|
|
146
|
+
|
|
147
|
+
# 3. Day-to-day
|
|
148
|
+
codex-accounts list # see everything at a glance
|
|
149
|
+
codex-accounts use work # switch back
|
|
150
|
+
codex-accounts quota # check 5h / weekly limits
|
|
151
|
+
codex-accounts stats # local token usage
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
See [docs/add-account.md](./docs/add-account.md) for a full walkthrough.
|
|
155
|
+
|
|
156
|
+
## Commands
|
|
157
|
+
|
|
158
|
+
| Command | What it does |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `list [-a]` | List saved accounts (`-a` adds live quota) |
|
|
161
|
+
| `current` | Print the active account |
|
|
162
|
+
| `info [name]` | Email, plan, token expiry, notes |
|
|
163
|
+
| `save <name> [--auth-only]` | Snapshot current Codex state under `<name>` |
|
|
164
|
+
| `use <name> [--no-share-sessions]` | Activate `<name>` (auto-saves the current one first) |
|
|
165
|
+
| `init <name>` | Create an empty snapshot directory (no copy) |
|
|
166
|
+
| `note <name> <text>` | Attach a free-text note |
|
|
167
|
+
| `rename <old> <new>` | Rename an account snapshot |
|
|
168
|
+
| `delete <name>` | Remove a snapshot directory |
|
|
169
|
+
| `stats [name]` | Token usage from local SQLite |
|
|
170
|
+
| `quota [name]` | Live quota via private API (requires network) |
|
|
171
|
+
| `refresh` | Open the 5h window on every account (cron-friendly) |
|
|
172
|
+
| `doctor` | Environment dump for bug reports |
|
|
173
|
+
|
|
174
|
+
Run `codex-accounts <cmd> --help` for command-specific options.
|
|
175
|
+
|
|
176
|
+
## How it works
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
~/.codex/ ~/.codex-accounts/work/
|
|
180
|
+
├── auth.json <─ swap ─> ├── auth.json
|
|
181
|
+
├── state_*.sqlite <─ swap ─> ├── state_*.sqlite (FULL mode only)
|
|
182
|
+
├── sessions/ <─ swap ─> ├── dirs/sessions/ (FULL mode only)
|
|
183
|
+
├── config.toml └── meta.json (notes, timestamps)
|
|
184
|
+
├── skills/ rules/ memories/ (shared across all accounts — never swapped)
|
|
185
|
+
└── installation_id
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- **AUTH mode** (default for `use`) — only `auth.json` is swapped. Sessions
|
|
189
|
+
and history live in `~/.codex/` and are visible from every account. This
|
|
190
|
+
is what most people want.
|
|
191
|
+
- **FULL mode** (`use --no-share-sessions`) — session DBs, log DBs, JSONL
|
|
192
|
+
index, the `sessions/`/`archived_sessions/`/`shell_snapshots/`
|
|
193
|
+
directories, and the global UI state file are all swapped. Use this when
|
|
194
|
+
you genuinely want separate session sidebars per account.
|
|
195
|
+
|
|
196
|
+
The exact file list lives in
|
|
197
|
+
[`src/codex_switch/config.py`](./src/codex_switch/config.py) and uses
|
|
198
|
+
**glob patterns** (`state_*.sqlite`, `logs_*.sqlite`) so future Codex
|
|
199
|
+
schema bumps don't silently miss files.
|
|
200
|
+
|
|
201
|
+
## Security & privacy
|
|
202
|
+
|
|
203
|
+
- `~/.codex-accounts/` and every snapshot directory inside it are created
|
|
204
|
+
with mode `0700`.
|
|
205
|
+
- Account names are validated against `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`
|
|
206
|
+
to prevent path traversal.
|
|
207
|
+
- `auth.json` is the same plaintext OAuth bundle the Codex client itself
|
|
208
|
+
stores. Treat snapshot directories with the same care you'd treat
|
|
209
|
+
`~/.codex/` — back them up, but do not check them into git or sync them
|
|
210
|
+
to anywhere unencrypted.
|
|
211
|
+
- `quota`, `list -a`, and `refresh` make network calls to
|
|
212
|
+
`chatgpt.com/backend-api`. All other commands are fully offline.
|
|
213
|
+
- See [`SECURITY.md`](./SECURITY.md) for vulnerability reporting.
|
|
214
|
+
|
|
215
|
+
## Disclaimers
|
|
216
|
+
|
|
217
|
+
- This project is **not affiliated with OpenAI** in any way.
|
|
218
|
+
- `quota`, `list -a`, and `refresh` rely on private endpoints
|
|
219
|
+
(`/backend-api/wham/usage` and `/backend-api/codex/responses`) that were
|
|
220
|
+
located by reverse-engineering the Codex desktop client. They can change
|
|
221
|
+
at any time. See
|
|
222
|
+
[`docs/internals/codex-app-internals.md`](./docs/internals/codex-app-internals.md)
|
|
223
|
+
for the methodology used to re-discover them when a Codex update breaks
|
|
224
|
+
the integration.
|
|
225
|
+
- `refresh` sends low-cost API requests on your behalf. Read
|
|
226
|
+
[`docs/auto-refresh.md`](./docs/auto-refresh.md) — including the
|
|
227
|
+
rate-limit and abuse-prevention caveats — before enabling the cron job.
|
|
228
|
+
|
|
229
|
+
## Contributing
|
|
230
|
+
|
|
231
|
+
Pull requests, bug reports, and "I tried it on Linux and X broke" notes
|
|
232
|
+
are all welcome. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the dev
|
|
233
|
+
loop, and [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) for community
|
|
234
|
+
expectations.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
[MIT](./LICENSE) © codex-accounts contributors.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
codex_switch/__init__.py,sha256=BiLmZ6V2piAf3RgFOqS6bgaBQ_XIWPh0UtuB7Vp_mvM,161
|
|
2
|
+
codex_switch/__main__.py,sha256=0DSFlsXKCZR4_qi1Xr1Db5sD92rVcy-DCWOeTlqrMYU,178
|
|
3
|
+
codex_switch/accounts.py,sha256=dnODuy9dJNxxNtppVyO-kE4jgdhBajFqE0q-L3zfb4I,8362
|
|
4
|
+
codex_switch/api.py,sha256=7MjdCxmzxTpvo0JhwvIbU9ShzYCVE0BPRfSTb58JCnc,4696
|
|
5
|
+
codex_switch/cli.py,sha256=uKnCvQBNijGCW4RQSyL-dL3Hdffo8HZzupZaibYZqg8,18510
|
|
6
|
+
codex_switch/config.py,sha256=wpJzHGJ5mzWBdJIFd-nHjXyWv72FAvaaoRsxl2cFx6k,3113
|
|
7
|
+
codex_switch/stats.py,sha256=ZEgn_MWc-rmct4OuA4qJVOmoW3i2Veb7H3XHUtHRsI0,4400
|
|
8
|
+
codex_switch/utils.py,sha256=wWU_MfQTbPvZbRGenipvj5nJqOaTH3R63INiW0zVxgk,8395
|
|
9
|
+
codex_accounts-0.1.0.dist-info/METADATA,sha256=3dcbjaJtJVZXn0iigYRTNgxv5Il6Sn7yz8WWy0x1gto,10410
|
|
10
|
+
codex_accounts-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
codex_accounts-0.1.0.dist-info/entry_points.txt,sha256=fNQGxFS_sj-X-ZESkv-fFQ_znoeEok4uWpYchhebsdE,57
|
|
12
|
+
codex_accounts-0.1.0.dist-info/licenses/LICENSE,sha256=_w2Q2dtFmeUQTQt6O7iit67OVvgDc04cbEqJbB1H3T4,1092
|
|
13
|
+
codex_accounts-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wikty and codex-switch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
codex_switch/__init__.py
ADDED
codex_switch/__main__.py
ADDED
codex_switch/accounts.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Account snapshot management: save / load / list / init / rename / delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from codex_switch import config, utils
|
|
14
|
+
from codex_switch.utils import AuthInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SwapMode(str, Enum):
|
|
18
|
+
"""How much state to swap when saving/loading an account.
|
|
19
|
+
|
|
20
|
+
* ``AUTH`` — only ``auth.json``. Sessions and history are shared across
|
|
21
|
+
accounts (the default).
|
|
22
|
+
* ``FULL`` — every per-account file and directory listed in
|
|
23
|
+
:mod:`codex_switch.config` is swapped, giving complete isolation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
AUTH = "auth"
|
|
27
|
+
FULL = "full"
|
|
28
|
+
|
|
29
|
+
def patterns(self) -> tuple[str, ...]:
|
|
30
|
+
return config.AUTH_FILES if self is SwapMode.AUTH else config.ACCOUNT_FILE_PATTERNS
|
|
31
|
+
|
|
32
|
+
def dirs(self) -> tuple[str, ...]:
|
|
33
|
+
return () if self is SwapMode.AUTH else config.ACCOUNT_DIRS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Account:
|
|
38
|
+
"""A snapshot directory under ``~/.codex-accounts/``."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
path: Path
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def auth_file(self) -> Path:
|
|
45
|
+
return self.path / "auth.json"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def meta_file(self) -> Path:
|
|
49
|
+
return self.path / "meta.json"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def dirs_root(self) -> Path:
|
|
53
|
+
return self.path / "dirs"
|
|
54
|
+
|
|
55
|
+
def read_meta(self) -> dict:
|
|
56
|
+
if not self.meta_file.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
with self.meta_file.open(encoding="utf-8") as fh:
|
|
60
|
+
data = json.load(fh)
|
|
61
|
+
return data if isinstance(data, dict) else {}
|
|
62
|
+
except (OSError, json.JSONDecodeError):
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
def update_meta(self, **changes: object) -> None:
|
|
66
|
+
meta = self.read_meta()
|
|
67
|
+
meta.update({k: v for k, v in changes.items() if v is not None})
|
|
68
|
+
utils.ensure_private_dir(self.path)
|
|
69
|
+
with self.meta_file.open("w", encoding="utf-8") as fh:
|
|
70
|
+
json.dump(meta, fh, ensure_ascii=False, indent=2)
|
|
71
|
+
fh.write("\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Current account pointer
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def current_name() -> str | None:
|
|
80
|
+
f = config.current_file()
|
|
81
|
+
if not f.exists():
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
name = f.read_text(encoding="utf-8").strip()
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
return name or None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_current(name: str) -> None:
|
|
91
|
+
utils.ensure_private_dir(config.accounts_home())
|
|
92
|
+
config.current_file().write_text(name + "\n", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Discovery
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_accounts() -> list[Account]:
|
|
101
|
+
home = config.accounts_home()
|
|
102
|
+
if not home.is_dir():
|
|
103
|
+
return []
|
|
104
|
+
out: list[Account] = []
|
|
105
|
+
for entry in sorted(home.iterdir(), key=lambda p: p.name.lower()):
|
|
106
|
+
if entry.name.startswith("."):
|
|
107
|
+
continue
|
|
108
|
+
if not entry.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
out.append(Account(name=entry.name, path=entry))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_account(name: str, *, must_exist: bool = True) -> Account:
|
|
115
|
+
utils.validate_account_name(name)
|
|
116
|
+
path = config.accounts_home() / name
|
|
117
|
+
if must_exist and not path.is_dir():
|
|
118
|
+
raise FileNotFoundError(f"account '{name}' not found under {config.accounts_home()}")
|
|
119
|
+
return Account(name=name, path=path)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def auth_file_for(name: str) -> Path:
|
|
123
|
+
"""Return the live or snapshotted ``auth.json`` for ``name``.
|
|
124
|
+
|
|
125
|
+
The currently active account reads from ``~/.codex/auth.json`` so we
|
|
126
|
+
pick up the freshest token; other accounts use their snapshot copy.
|
|
127
|
+
"""
|
|
128
|
+
if current_name() == name:
|
|
129
|
+
return config.codex_home() / "auth.json"
|
|
130
|
+
return config.accounts_home() / name / "auth.json"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_auth(name: str) -> AuthInfo | None:
|
|
134
|
+
return utils.decode_auth_file(auth_file_for(name))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Snapshot mutations
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _iter_matches(root: Path, patterns: Iterable[str]) -> Iterator[Path]:
|
|
143
|
+
seen: set[Path] = set()
|
|
144
|
+
for pattern in patterns:
|
|
145
|
+
for match in sorted(root.glob(pattern)):
|
|
146
|
+
if match in seen:
|
|
147
|
+
continue
|
|
148
|
+
seen.add(match)
|
|
149
|
+
yield match
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def init_account(name: str) -> Account:
|
|
153
|
+
"""Create an empty snapshot directory (no files copied)."""
|
|
154
|
+
utils.validate_account_name(name)
|
|
155
|
+
home = config.accounts_home()
|
|
156
|
+
utils.ensure_private_dir(home)
|
|
157
|
+
path = home / name
|
|
158
|
+
if path.exists():
|
|
159
|
+
raise FileExistsError(f"account '{name}' already exists at {path}")
|
|
160
|
+
utils.ensure_private_dir(path)
|
|
161
|
+
utils.ensure_private_dir(path / "dirs")
|
|
162
|
+
Account(name=name, path=path).update_meta(
|
|
163
|
+
created_at=_now_iso(),
|
|
164
|
+
)
|
|
165
|
+
return Account(name=name, path=path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def save_account(name: str, mode: SwapMode = SwapMode.FULL) -> Account:
|
|
169
|
+
"""Copy live Codex state into the account snapshot directory."""
|
|
170
|
+
utils.validate_account_name(name)
|
|
171
|
+
home = config.accounts_home()
|
|
172
|
+
utils.ensure_private_dir(home)
|
|
173
|
+
dest = home / name
|
|
174
|
+
utils.ensure_private_dir(dest)
|
|
175
|
+
utils.ensure_private_dir(dest / "dirs")
|
|
176
|
+
|
|
177
|
+
src = config.codex_home()
|
|
178
|
+
if not src.is_dir():
|
|
179
|
+
raise FileNotFoundError(
|
|
180
|
+
f"Codex home not found at {src} — is the Codex desktop app installed?"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for live in _iter_matches(src, mode.patterns()):
|
|
184
|
+
if not live.is_file():
|
|
185
|
+
continue
|
|
186
|
+
shutil.copy2(live, dest / live.name)
|
|
187
|
+
|
|
188
|
+
for d in mode.dirs():
|
|
189
|
+
src_dir = src / d
|
|
190
|
+
if not src_dir.is_dir():
|
|
191
|
+
continue
|
|
192
|
+
target = dest / "dirs" / d
|
|
193
|
+
if target.exists():
|
|
194
|
+
shutil.rmtree(target)
|
|
195
|
+
shutil.copytree(src_dir, target, symlinks=True)
|
|
196
|
+
|
|
197
|
+
account = Account(name=name, path=dest)
|
|
198
|
+
account.update_meta(last_used_at=_now_iso())
|
|
199
|
+
return account
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_account(name: str, mode: SwapMode = SwapMode.FULL) -> Account:
|
|
203
|
+
"""Copy the account snapshot back into the live Codex home."""
|
|
204
|
+
account = get_account(name, must_exist=True)
|
|
205
|
+
dest = config.codex_home()
|
|
206
|
+
utils.ensure_private_dir(dest)
|
|
207
|
+
|
|
208
|
+
# Files: replace if snapshot has them, delete from live home if not.
|
|
209
|
+
snap_files = {p.name: p for p in _iter_matches(account.path, mode.patterns())}
|
|
210
|
+
live_files = {p.name: p for p in _iter_matches(dest, mode.patterns())}
|
|
211
|
+
|
|
212
|
+
for name_, src in snap_files.items():
|
|
213
|
+
shutil.copy2(src, dest / name_)
|
|
214
|
+
|
|
215
|
+
for name_, live in live_files.items():
|
|
216
|
+
if name_ not in snap_files:
|
|
217
|
+
live.unlink(missing_ok=True)
|
|
218
|
+
|
|
219
|
+
# Directories: only swap when in FULL mode; preserve live dirs otherwise.
|
|
220
|
+
for d in mode.dirs():
|
|
221
|
+
live_dir = dest / d
|
|
222
|
+
snap_dir = account.path / "dirs" / d
|
|
223
|
+
if snap_dir.is_dir():
|
|
224
|
+
if live_dir.exists():
|
|
225
|
+
shutil.rmtree(live_dir)
|
|
226
|
+
shutil.copytree(snap_dir, live_dir, symlinks=True)
|
|
227
|
+
|
|
228
|
+
set_current(name)
|
|
229
|
+
account.update_meta(last_used_at=_now_iso())
|
|
230
|
+
return account
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def rename_account(old: str, new: str) -> Account:
|
|
234
|
+
src = get_account(old, must_exist=True)
|
|
235
|
+
utils.validate_account_name(new)
|
|
236
|
+
dst_path = config.accounts_home() / new
|
|
237
|
+
if dst_path.exists():
|
|
238
|
+
raise FileExistsError(f"account '{new}' already exists")
|
|
239
|
+
src.path.rename(dst_path)
|
|
240
|
+
if current_name() == old:
|
|
241
|
+
set_current(new)
|
|
242
|
+
return Account(name=new, path=dst_path)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def delete_account(name: str, *, dry_run: bool = False) -> Path:
|
|
246
|
+
account = get_account(name, must_exist=True)
|
|
247
|
+
if current_name() == name:
|
|
248
|
+
raise RuntimeError(f"cannot delete the currently active account '{name}'; switch first")
|
|
249
|
+
if not dry_run:
|
|
250
|
+
shutil.rmtree(account.path)
|
|
251
|
+
return account.path
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def set_note(name: str, text: str) -> None:
|
|
255
|
+
account = get_account(name, must_exist=True)
|
|
256
|
+
account.update_meta(notes=text)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Misc
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _now_iso() -> str:
|
|
265
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|