@alphaedge/codex-profile 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 +74 -0
- package/bin/codex-profile +282 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 REPO_OWNER
|
|
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,74 @@
|
|
|
1
|
+
# codex-profile
|
|
2
|
+
|
|
3
|
+
CLI to switch between Codex config/auth profiles.
|
|
4
|
+
|
|
5
|
+
It manages these files in your Codex home (`$CODEX_HOME`, `~/.config/codex`, or `~/.codex`):
|
|
6
|
+
|
|
7
|
+
- `config.toml`
|
|
8
|
+
- `auth.json`
|
|
9
|
+
|
|
10
|
+
Profiles are stored as sibling files with a suffix:
|
|
11
|
+
|
|
12
|
+
- `config.toml.<profile>`
|
|
13
|
+
- `auth.json.<profile>`
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
### npm
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i -g @alphaedge/codex-profile
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Homebrew
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
brew tap REPO_OWNER/codex-profile
|
|
27
|
+
brew install codex-profile
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
codex-profile show
|
|
34
|
+
codex-profile save openai
|
|
35
|
+
codex-profile switch openai
|
|
36
|
+
codex-profile switch bk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`switch` creates a timestamped backup in `profile_backups/<timestamp>/` by default.
|
|
40
|
+
|
|
41
|
+
## Release to npm
|
|
42
|
+
|
|
43
|
+
1. Update `version` in `package.json`.
|
|
44
|
+
2. Commit + tag:
|
|
45
|
+
```bash
|
|
46
|
+
git tag v0.1.0
|
|
47
|
+
git push origin main --tags
|
|
48
|
+
```
|
|
49
|
+
3. Publish:
|
|
50
|
+
```bash
|
|
51
|
+
npm login
|
|
52
|
+
npm publish
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Release to Homebrew
|
|
56
|
+
|
|
57
|
+
1. Create tap repo named `homebrew-codex-profile` under your GitHub account.
|
|
58
|
+
2. Copy `Formula/codex-profile.rb` into that tap repo at `Formula/codex-profile.rb`.
|
|
59
|
+
3. Replace placeholders:
|
|
60
|
+
- `REPO_OWNER`
|
|
61
|
+
- `REPO_VERSION`
|
|
62
|
+
- `REPO_SHA256`
|
|
63
|
+
4. Compute tarball sha256:
|
|
64
|
+
```bash
|
|
65
|
+
curl -L https://github.com/REPO_OWNER/codex-profile/archive/refs/tags/vREPO_VERSION.tar.gz | shasum -a 256
|
|
66
|
+
```
|
|
67
|
+
5. Commit formula change in the tap repo.
|
|
68
|
+
|
|
69
|
+
## Local dev
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
./bin/codex-profile --help
|
|
73
|
+
./bin/codex-profile show
|
|
74
|
+
```
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manage Codex config/auth profile files.
|
|
3
|
+
|
|
4
|
+
Profiles are stored as sibling files in the Codex home:
|
|
5
|
+
- config.toml.<profile>
|
|
6
|
+
- auth.json.<profile>
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
- config.toml.bk + auth.json.bk => profile name "bk"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
BASE_FILES = ("config.toml", "auth.json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProfileError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_codex_home(explicit_home: str | None) -> Path:
|
|
31
|
+
candidates: list[Path] = []
|
|
32
|
+
if explicit_home:
|
|
33
|
+
candidates.append(Path(explicit_home).expanduser())
|
|
34
|
+
else:
|
|
35
|
+
env_home = os.environ.get("CODEX_HOME")
|
|
36
|
+
if env_home:
|
|
37
|
+
candidates.append(Path(env_home).expanduser())
|
|
38
|
+
candidates.extend([
|
|
39
|
+
Path.home() / ".config" / "codex",
|
|
40
|
+
Path.home() / ".codex",
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
for candidate in candidates:
|
|
44
|
+
if (candidate / "config.toml").exists() or (candidate / "auth.json").exists():
|
|
45
|
+
return candidate
|
|
46
|
+
for candidate in candidates:
|
|
47
|
+
if candidate.exists():
|
|
48
|
+
return candidate
|
|
49
|
+
|
|
50
|
+
looked = ", ".join(str(p) for p in candidates)
|
|
51
|
+
raise ProfileError(f"Could not find Codex home. Checked: {looked}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_profile_name(name: str) -> None:
|
|
55
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name):
|
|
56
|
+
raise ProfileError(
|
|
57
|
+
"Invalid profile name. Use letters, numbers, dash, underscore, or dot."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def file_digest(path: Path) -> str:
|
|
62
|
+
h = hashlib.sha256()
|
|
63
|
+
with path.open("rb") as f:
|
|
64
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
65
|
+
h.update(chunk)
|
|
66
|
+
return h.hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_toml_value(text: str, key: str) -> str:
|
|
70
|
+
# Minimal parser for top-level key = "value" lines.
|
|
71
|
+
pattern = rf"(?m)^\s*{re.escape(key)}\s*=\s*\"([^\"]+)\"\s*$"
|
|
72
|
+
match = re.search(pattern, text)
|
|
73
|
+
return match.group(1).strip() if match else "?"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_provider_base_url(text: str, provider: str) -> str:
|
|
77
|
+
if provider == "?":
|
|
78
|
+
return ""
|
|
79
|
+
section = re.search(
|
|
80
|
+
rf"(?ms)^\s*\[model_providers\.{re.escape(provider)}\]\s*(.*?)(?=^\s*\[|\Z)",
|
|
81
|
+
text,
|
|
82
|
+
)
|
|
83
|
+
if not section:
|
|
84
|
+
return ""
|
|
85
|
+
body = section.group(1)
|
|
86
|
+
match = re.search(r'(?m)^\s*base_url\s*=\s*"([^"]+)"\s*$', body)
|
|
87
|
+
return match.group(1).strip() if match else ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_provider_summary(config_path: Path) -> str:
|
|
91
|
+
try:
|
|
92
|
+
text = config_path.read_text(encoding="utf-8", errors="ignore")
|
|
93
|
+
except Exception:
|
|
94
|
+
return "provider=? model=?"
|
|
95
|
+
|
|
96
|
+
provider = parse_toml_value(text, "model_provider")
|
|
97
|
+
model = parse_toml_value(text, "model")
|
|
98
|
+
base_url = parse_provider_base_url(text, provider)
|
|
99
|
+
|
|
100
|
+
if base_url:
|
|
101
|
+
return f"provider={provider} model={model} base_url={base_url}"
|
|
102
|
+
return f"provider={provider} model={model}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def profile_paths(home: Path, profile: str) -> dict[str, Path]:
|
|
106
|
+
return {base: home / f"{base}.{profile}" for base in BASE_FILES}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def collect_profiles(home: Path) -> dict[str, dict[str, Path]]:
|
|
110
|
+
found: dict[str, dict[str, Path]] = {}
|
|
111
|
+
for base in BASE_FILES:
|
|
112
|
+
for file_path in home.glob(f"{base}.*"):
|
|
113
|
+
profile = file_path.name[len(base) + 1 :]
|
|
114
|
+
if not profile:
|
|
115
|
+
continue
|
|
116
|
+
found.setdefault(profile, {})[base] = file_path
|
|
117
|
+
return found
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def atomic_copy(src: Path, dst: Path) -> None:
|
|
121
|
+
tmp = dst.with_name(dst.name + ".tmp")
|
|
122
|
+
shutil.copy2(src, tmp)
|
|
123
|
+
os.replace(tmp, dst)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def backup_active(home: Path) -> Path:
|
|
127
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
128
|
+
backup_dir = home / "profile_backups" / stamp
|
|
129
|
+
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
130
|
+
for base in BASE_FILES:
|
|
131
|
+
src = home / base
|
|
132
|
+
if src.exists():
|
|
133
|
+
shutil.copy2(src, backup_dir / base)
|
|
134
|
+
return backup_dir
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_show(home: Path) -> int:
|
|
138
|
+
config_path = home / "config.toml"
|
|
139
|
+
auth_path = home / "auth.json"
|
|
140
|
+
|
|
141
|
+
print(f"Codex home: {home}")
|
|
142
|
+
if config_path.exists():
|
|
143
|
+
print(f"Active config: {config_path}")
|
|
144
|
+
print(f" {load_provider_summary(config_path)}")
|
|
145
|
+
else:
|
|
146
|
+
print("Active config: missing")
|
|
147
|
+
|
|
148
|
+
if auth_path.exists():
|
|
149
|
+
print(f"Active auth: {auth_path}")
|
|
150
|
+
else:
|
|
151
|
+
print("Active auth: missing")
|
|
152
|
+
|
|
153
|
+
profiles = collect_profiles(home)
|
|
154
|
+
if not profiles:
|
|
155
|
+
print("Profiles: none")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
active_digests: dict[str, str] = {}
|
|
159
|
+
for base in BASE_FILES:
|
|
160
|
+
p = home / base
|
|
161
|
+
if p.exists():
|
|
162
|
+
active_digests[base] = file_digest(p)
|
|
163
|
+
|
|
164
|
+
print("Profiles:")
|
|
165
|
+
for name in sorted(profiles):
|
|
166
|
+
files = profiles[name]
|
|
167
|
+
has_all = all(base in files for base in BASE_FILES)
|
|
168
|
+
status = "ok" if has_all else "incomplete"
|
|
169
|
+
|
|
170
|
+
active_match = False
|
|
171
|
+
if has_all and active_digests:
|
|
172
|
+
active_match = all(
|
|
173
|
+
active_digests.get(base) == file_digest(files[base])
|
|
174
|
+
for base in BASE_FILES
|
|
175
|
+
if base in active_digests
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
marker = "*" if active_match else " "
|
|
179
|
+
summary = ""
|
|
180
|
+
if "config.toml" in files:
|
|
181
|
+
summary = load_provider_summary(files["config.toml"])
|
|
182
|
+
print(f" {marker} {name:<18} [{status}] {summary}".rstrip())
|
|
183
|
+
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_save(home: Path, profile: str, force: bool) -> int:
|
|
188
|
+
validate_profile_name(profile)
|
|
189
|
+
src_paths = {base: home / base for base in BASE_FILES}
|
|
190
|
+
for base, path in src_paths.items():
|
|
191
|
+
if not path.exists():
|
|
192
|
+
raise ProfileError(f"Missing active {base}: {path}")
|
|
193
|
+
|
|
194
|
+
dst_paths = profile_paths(home, profile)
|
|
195
|
+
if not force:
|
|
196
|
+
existing = [str(p) for p in dst_paths.values() if p.exists()]
|
|
197
|
+
if existing:
|
|
198
|
+
joined = ", ".join(existing)
|
|
199
|
+
raise ProfileError(f"Profile already exists. Use --force to overwrite: {joined}")
|
|
200
|
+
|
|
201
|
+
for base in BASE_FILES:
|
|
202
|
+
atomic_copy(src_paths[base], dst_paths[base])
|
|
203
|
+
|
|
204
|
+
print(f"Saved active files to profile '{profile}'.")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_switch(home: Path, profile: str, no_backup: bool) -> int:
|
|
209
|
+
validate_profile_name(profile)
|
|
210
|
+
src_paths = profile_paths(home, profile)
|
|
211
|
+
|
|
212
|
+
missing = [str(p) for p in src_paths.values() if not p.exists()]
|
|
213
|
+
if missing:
|
|
214
|
+
expected = ", ".join(str(p) for p in src_paths.values())
|
|
215
|
+
raise ProfileError(
|
|
216
|
+
f"Profile files missing for profile '{profile}'. Expected: {expected}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if no_backup:
|
|
220
|
+
print("Skipping backup (--no-backup).")
|
|
221
|
+
else:
|
|
222
|
+
backup_dir = backup_active(home)
|
|
223
|
+
print(f"Backed up active files to: {backup_dir}")
|
|
224
|
+
|
|
225
|
+
for base in BASE_FILES:
|
|
226
|
+
atomic_copy(src_paths[base], home / base)
|
|
227
|
+
|
|
228
|
+
print(f"Switched active Codex config to profile '{profile}'.")
|
|
229
|
+
print(f" {load_provider_summary(home / 'config.toml')}")
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
prog="codex-profile",
|
|
236
|
+
description="Manage Codex config/auth profile files.",
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--home",
|
|
240
|
+
help="Codex home dir (defaults: $CODEX_HOME, ~/.config/codex, ~/.codex).",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
244
|
+
|
|
245
|
+
sub.add_parser("show", help="Show active config and discovered profiles.")
|
|
246
|
+
|
|
247
|
+
p_save = sub.add_parser("save", help="Save active config/auth as a named profile.")
|
|
248
|
+
p_save.add_argument("profile", help="Profile name, e.g. openai, local, bk")
|
|
249
|
+
p_save.add_argument("--force", action="store_true", help="Overwrite existing profile")
|
|
250
|
+
|
|
251
|
+
p_switch = sub.add_parser("switch", help="Switch active config/auth to a profile.")
|
|
252
|
+
p_switch.add_argument("profile", help="Profile name, e.g. openai, local, bk")
|
|
253
|
+
p_switch.add_argument(
|
|
254
|
+
"--no-backup",
|
|
255
|
+
action="store_true",
|
|
256
|
+
help="Do not save a timestamped backup before switching.",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return parser
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: list[str] | None = None) -> int:
|
|
263
|
+
parser = build_parser()
|
|
264
|
+
args = parser.parse_args(argv)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
home = detect_codex_home(args.home)
|
|
268
|
+
if args.command == "show":
|
|
269
|
+
return cmd_show(home)
|
|
270
|
+
if args.command == "save":
|
|
271
|
+
return cmd_save(home, args.profile, args.force)
|
|
272
|
+
if args.command == "switch":
|
|
273
|
+
return cmd_switch(home, args.profile, args.no_backup)
|
|
274
|
+
parser.print_help()
|
|
275
|
+
return 1
|
|
276
|
+
except ProfileError as exc:
|
|
277
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
raise SystemExit(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alphaedge/codex-profile",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Switch between Codex config/auth profiles",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-profile": "bin/codex-profile"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"codex",
|
|
16
|
+
"cli",
|
|
17
|
+
"config"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/REPO_OWNER/codex-profile.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/REPO_OWNER/codex-profile/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/REPO_OWNER/codex-profile#readme",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|