codex-slot 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/account-store.js +207 -0
- package/dist/cli.js +580 -0
- package/dist/config.js +205 -0
- package/dist/login.js +45 -0
- package/dist/scheduler.js +97 -0
- package/dist/serve.js +24 -0
- package/dist/server.js +402 -0
- package/dist/state.js +121 -0
- package/dist/status.js +199 -0
- package/dist/types.js +2 -0
- package/dist/usage-sync.js +189 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bk
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# codex-slot
|
|
2
|
+
|
|
3
|
+
`codex-slot` is a local multi-account / multi-workspace switcher for Codex.
|
|
4
|
+
|
|
5
|
+
[中文文档](./docs/zh-CN.md)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Reuse the official `~/.codex` login state
|
|
10
|
+
- Manage multiple accounts or workspaces as separate slots
|
|
11
|
+
- Refresh and cache the latest usage from the official usage endpoint
|
|
12
|
+
- Expose a local provider endpoint for Codex
|
|
13
|
+
- Apply local block rules for temporary, 5-hour, and weekly limits
|
|
14
|
+
- Automatically switch `~/.codex/config.toml` to the `cslot` provider while the local proxy is running (and restore it on stop)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm i -g codex-slot
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Verify:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
codex-slot --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This repository is the source repository.
|
|
29
|
+
GitHub installation from the repository URL is not supported.
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
Import your current Codex login state:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
codex-slot import current ~
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`import` copies the official login state into `~/.cslot/homes/<name>` instead of referencing the source HOME directly.
|
|
40
|
+
|
|
41
|
+
Check latest usage:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
codex-slot status
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
By default, `status` will:
|
|
48
|
+
|
|
49
|
+
- Refresh usage for all managed accounts
|
|
50
|
+
- Render a compact table with:
|
|
51
|
+
- Remaining 5-hour / weekly quotas
|
|
52
|
+
- Reset times
|
|
53
|
+
- A status column with local block reasons and countdowns (for example: `5h_limited(2h27m)`)
|
|
54
|
+
- Enter an interactive mode where you can toggle `enabled` for accounts:
|
|
55
|
+
- Up/Down: move selection
|
|
56
|
+
- Space: toggle `[x]` enabled / `[ ]` disabled and save immediately
|
|
57
|
+
- Enter / `q`: exit the interactive mode
|
|
58
|
+
|
|
59
|
+
If you only want a non-interactive snapshot of the current state:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
codex-slot status --no-interactive
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Start the local proxy:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
codex-slot start
|
|
69
|
+
codex-slot start --port 4399
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`start` will automatically write the required provider config into `~/.codex/config.toml`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
codex-slot start
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
codex-slot add <name>
|
|
82
|
+
codex-slot del <name>
|
|
83
|
+
codex-slot import <name> [HOME]
|
|
84
|
+
codex-slot status
|
|
85
|
+
codex-slot start [--port <port>]
|
|
86
|
+
codex-slot stop
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## How `status` Works
|
|
90
|
+
|
|
91
|
+
`codex-slot status` does not render stale data from the official `registry.json` cache.
|
|
92
|
+
|
|
93
|
+
Instead it:
|
|
94
|
+
|
|
95
|
+
1. Reads `access_token`, `refresh_token`, and `account_id` from the official Codex login state
|
|
96
|
+
2. Requests `https://chatgpt.com/backend-api/wham/usage`
|
|
97
|
+
3. Stores the latest result in `~/.cslot/state.json`
|
|
98
|
+
4. Renders the latest local cache
|
|
99
|
+
|
|
100
|
+
## Managed Codex Config
|
|
101
|
+
|
|
102
|
+
`codex-slot start` writes or updates a provider block like this, based on the current `~/.cslot/config.yaml`:
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[model_providers.cslot]
|
|
106
|
+
name = "cslot"
|
|
107
|
+
base_url = "http://127.0.0.1:4389/v1"
|
|
108
|
+
http_headers = { Authorization = "Bearer cslot-defaultkey" }
|
|
109
|
+
wire_api = "responses"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Behavior:
|
|
113
|
+
|
|
114
|
+
- If global `model_provider` or `# model_provider = ...` exists, it is normalized to `model_provider = "cslot"`
|
|
115
|
+
- If `[model_providers.cslot]` already exists, only that provider block is replaced with the fresh one above
|
|
116
|
+
- Other providers and settings in `config.toml` are left untouched
|
|
117
|
+
- If you start with `--port`, the port is saved to `~/.cslot/config.yaml`
|
|
118
|
+
- `cslot stop` comments out the active `model_provider = "cslot"` line and keeps the rest of the file unchanged
|
|
119
|
+
|
|
120
|
+
## Data Directory
|
|
121
|
+
|
|
122
|
+
`codex-slot` uses:
|
|
123
|
+
|
|
124
|
+
- `~/.cslot/config.yaml`
|
|
125
|
+
- `~/.cslot/state.json`
|
|
126
|
+
- `~/.cslot/cslot.pid`
|
|
127
|
+
- `~/.cslot/logs/service.log`
|
|
128
|
+
|
|
129
|
+
If you previously used `~/.codexsw`, it is migrated automatically.
|
|
130
|
+
|
|
131
|
+
## Limit Handling
|
|
132
|
+
|
|
133
|
+
- Weekly limit: blocked until the weekly reset time
|
|
134
|
+
- 5-hour limit: blocked until the 5-hour reset time
|
|
135
|
+
- Temporary limit: blocked for 5 minutes
|
|
136
|
+
|
|
137
|
+
## Repository
|
|
138
|
+
|
|
139
|
+
- GitHub: https://github.com/openxiaobu/cslot
|
|
140
|
+
- Issues: https://github.com/openxiaobu/cslot/issues
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm install
|
|
146
|
+
npm run build
|
|
147
|
+
npm run check
|
|
148
|
+
```
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getCodexDataDir = getCodexDataDir;
|
|
7
|
+
exports.readRegistry = readRegistry;
|
|
8
|
+
exports.readAuthFile = readAuthFile;
|
|
9
|
+
exports.cloneCodexAuthState = cloneCodexAuthState;
|
|
10
|
+
exports.hasCompleteCodexAuthState = hasCompleteCodexAuthState;
|
|
11
|
+
exports.writeAuthFile = writeAuthFile;
|
|
12
|
+
exports.resolvePrimaryRegistryAccount = resolvePrimaryRegistryAccount;
|
|
13
|
+
exports.registerManagedAccount = registerManagedAccount;
|
|
14
|
+
exports.removeManagedAccount = removeManagedAccount;
|
|
15
|
+
exports.findManagedAccount = findManagedAccount;
|
|
16
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
+
const config_1 = require("./config");
|
|
19
|
+
/**
|
|
20
|
+
* 读取指定账号 HOME 下的 `.codex` 目录。
|
|
21
|
+
*
|
|
22
|
+
* @param codexHome 账号独立 HOME 目录。
|
|
23
|
+
* @returns `.codex` 目录绝对路径。
|
|
24
|
+
*/
|
|
25
|
+
function getCodexDataDir(codexHome) {
|
|
26
|
+
return node_path_1.default.join((0, config_1.expandHome)(codexHome), ".codex");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 读取某账号对应的 `registry.json`。
|
|
30
|
+
*
|
|
31
|
+
* @param codexHome 账号独立 HOME 目录。
|
|
32
|
+
* @returns 解析后的 registry;不存在时返回 `null`。
|
|
33
|
+
*/
|
|
34
|
+
function readRegistry(codexHome) {
|
|
35
|
+
const registryPath = node_path_1.default.join(getCodexDataDir(codexHome), "accounts", "registry.json");
|
|
36
|
+
if (!node_fs_1.default.existsSync(registryPath)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(node_fs_1.default.readFileSync(registryPath, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 读取账号目录下当前激活凭据文件。
|
|
43
|
+
*
|
|
44
|
+
* @param codexHome 账号独立 HOME 目录。
|
|
45
|
+
* @returns 解析后的 auth.json;不存在时返回 `null`。
|
|
46
|
+
*/
|
|
47
|
+
function readAuthFile(codexHome) {
|
|
48
|
+
const authPath = node_path_1.default.join(getCodexDataDir(codexHome), "auth.json");
|
|
49
|
+
if (!node_fs_1.default.existsSync(authPath)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 从 `id_token` 中解析邮箱。
|
|
56
|
+
*
|
|
57
|
+
* @param auth 认证文件对象。
|
|
58
|
+
* @returns 邮箱地址;缺失或解析失败时返回 `undefined`。
|
|
59
|
+
*/
|
|
60
|
+
function resolveEmailFromAuth(auth) {
|
|
61
|
+
const idToken = auth?.tokens?.id_token;
|
|
62
|
+
if (!idToken) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(Buffer.from(idToken.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
67
|
+
return payload.email;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 将来源 HOME 下的官方 `.codex` 登录态复制到目标 HOME。
|
|
75
|
+
*
|
|
76
|
+
* 只复制认证和账号元数据所需文件,不复制历史日志、缓存等无关内容。
|
|
77
|
+
*
|
|
78
|
+
* @param sourceHome 来源 HOME 目录。
|
|
79
|
+
* @param targetHome 目标 HOME 目录。
|
|
80
|
+
* @returns 无返回值。
|
|
81
|
+
* @throws 当来源目录缺少关键认证文件时抛出错误。
|
|
82
|
+
*/
|
|
83
|
+
function cloneCodexAuthState(sourceHome, targetHome) {
|
|
84
|
+
const sourceCodexDir = getCodexDataDir(sourceHome);
|
|
85
|
+
const targetCodexDir = getCodexDataDir(targetHome);
|
|
86
|
+
const sourceAuthPath = node_path_1.default.join(sourceCodexDir, "auth.json");
|
|
87
|
+
const sourceAccountsDir = node_path_1.default.join(sourceCodexDir, "accounts");
|
|
88
|
+
const sourceRegistryPath = node_path_1.default.join(sourceAccountsDir, "registry.json");
|
|
89
|
+
if (!node_fs_1.default.existsSync(sourceAuthPath)) {
|
|
90
|
+
throw new Error(`来源目录缺少 auth.json: ${sourceAuthPath}`);
|
|
91
|
+
}
|
|
92
|
+
if (!node_fs_1.default.existsSync(sourceRegistryPath)) {
|
|
93
|
+
throw new Error(`来源目录缺少 registry.json: ${sourceRegistryPath}`);
|
|
94
|
+
}
|
|
95
|
+
node_fs_1.default.mkdirSync(targetCodexDir, { recursive: true });
|
|
96
|
+
node_fs_1.default.mkdirSync(node_path_1.default.join(targetCodexDir, "accounts"), { recursive: true });
|
|
97
|
+
node_fs_1.default.copyFileSync(sourceAuthPath, node_path_1.default.join(targetCodexDir, "auth.json"));
|
|
98
|
+
node_fs_1.default.copyFileSync(sourceRegistryPath, node_path_1.default.join(targetCodexDir, "accounts", "registry.json"));
|
|
99
|
+
for (const entry of node_fs_1.default.readdirSync(sourceAccountsDir, { withFileTypes: true })) {
|
|
100
|
+
if (!entry.isFile()) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!entry.name.endsWith(".auth.json")) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
node_fs_1.default.copyFileSync(node_path_1.default.join(sourceAccountsDir, entry.name), node_path_1.default.join(targetCodexDir, "accounts", entry.name));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 检查某个 HOME 下的官方登录态是否完整。
|
|
111
|
+
*
|
|
112
|
+
* 完整标准:
|
|
113
|
+
* 1. 存在 `.codex/auth.json`
|
|
114
|
+
* 2. `auth.json` 中存在 `access_token`
|
|
115
|
+
* 3. `auth.json` 中存在 `refresh_token`
|
|
116
|
+
* 4. `auth.json` 中存在 `account_id`
|
|
117
|
+
*
|
|
118
|
+
* @param codexHome 待检查的 HOME 目录。
|
|
119
|
+
* @returns 为 `true` 表示登录态完整,可用于调度;否则为 `false`。
|
|
120
|
+
*/
|
|
121
|
+
function hasCompleteCodexAuthState(codexHome) {
|
|
122
|
+
const auth = readAuthFile(codexHome);
|
|
123
|
+
return Boolean(auth?.tokens?.access_token &&
|
|
124
|
+
auth?.tokens?.refresh_token &&
|
|
125
|
+
auth?.tokens?.account_id);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 将最新认证信息回写到指定账号的 `auth.json`。
|
|
129
|
+
*
|
|
130
|
+
* @param codexHome 账号独立 HOME 目录。
|
|
131
|
+
* @param auth 最新认证信息。
|
|
132
|
+
* @returns 无返回值。
|
|
133
|
+
*/
|
|
134
|
+
function writeAuthFile(codexHome, auth) {
|
|
135
|
+
const authPath = node_path_1.default.join(getCodexDataDir(codexHome), "auth.json");
|
|
136
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(authPath), { recursive: true });
|
|
137
|
+
node_fs_1.default.writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`, "utf8");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 根据当前账号目录中的 registry 推断主账号信息。
|
|
141
|
+
*
|
|
142
|
+
* @param codexHome 账号独立 HOME 目录。
|
|
143
|
+
* @returns 当前活跃账号元数据;无可用账号时返回 `null`。
|
|
144
|
+
*/
|
|
145
|
+
function resolvePrimaryRegistryAccount(codexHome) {
|
|
146
|
+
const registry = readRegistry(codexHome);
|
|
147
|
+
if (!registry || registry.accounts.length === 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (registry.active_email) {
|
|
151
|
+
const active = registry.accounts.find((item) => item.email === registry.active_email);
|
|
152
|
+
if (active) {
|
|
153
|
+
return active;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return registry.accounts[0] ?? null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 将账号注册到 cslot 配置中,并为其准备独立 HOME 目录。
|
|
160
|
+
*
|
|
161
|
+
* @param accountId 本地账号标识。
|
|
162
|
+
* @param codexHome 可选的自定义 HOME 目录;未提供时使用默认路径。
|
|
163
|
+
* @returns 写入后的账号配置。
|
|
164
|
+
*/
|
|
165
|
+
function registerManagedAccount(accountId, codexHome) {
|
|
166
|
+
const home = codexHome ? (0, config_1.expandHome)(codexHome) : (0, config_1.getManagedHome)(accountId);
|
|
167
|
+
// 预先创建账号隔离目录,方便后续直接执行 codex login。
|
|
168
|
+
node_fs_1.default.mkdirSync(home, { recursive: true });
|
|
169
|
+
const primary = resolvePrimaryRegistryAccount(home);
|
|
170
|
+
const auth = readAuthFile(home);
|
|
171
|
+
const account = {
|
|
172
|
+
id: accountId,
|
|
173
|
+
name: accountId,
|
|
174
|
+
codex_home: home,
|
|
175
|
+
email: primary?.email ?? resolveEmailFromAuth(auth),
|
|
176
|
+
enabled: true,
|
|
177
|
+
imported_at: new Date().toISOString()
|
|
178
|
+
};
|
|
179
|
+
(0, config_1.upsertAccount)(account);
|
|
180
|
+
return account;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 从配置中删除指定账号;默认仅删除配置项,不主动删除本地 HOME 目录。
|
|
184
|
+
*
|
|
185
|
+
* @param accountId 本地账号标识。
|
|
186
|
+
* @returns 被删除的账号配置;未命中时返回 `null`。
|
|
187
|
+
*/
|
|
188
|
+
function removeManagedAccount(accountId) {
|
|
189
|
+
const config = (0, config_1.loadConfig)();
|
|
190
|
+
const index = config.accounts.findIndex((item) => item.id === accountId);
|
|
191
|
+
if (index < 0) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const [removed] = config.accounts.splice(index, 1);
|
|
195
|
+
(0, config_1.saveConfig)(config);
|
|
196
|
+
return removed ?? null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 根据账号标识读取配置中的账号项。
|
|
200
|
+
*
|
|
201
|
+
* @param accountId 本地账号标识。
|
|
202
|
+
* @returns 命中的账号配置;未命中时返回 `null`。
|
|
203
|
+
*/
|
|
204
|
+
function findManagedAccount(accountId) {
|
|
205
|
+
const config = (0, config_1.loadConfig)();
|
|
206
|
+
return config.accounts.find((item) => item.id === accountId) ?? null;
|
|
207
|
+
}
|