@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 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
+ }