@wangxt0223/codex-switcher 0.5.0 → 0.6.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.
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage"
14
+ DASH = "-"
15
+ KNOWN_PLANS = {"free", "plus", "pro", "team", "business", "enterprise", "edu"}
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description="Collect account usage metrics for codex-switcher list output.")
20
+ parser.add_argument("--account-name", required=True)
21
+ parser.add_argument("--auth-file", required=True)
22
+ parser.add_argument("--data-path", required=True)
23
+ parser.add_argument("--timeout-seconds", type=int, default=4)
24
+ return parser.parse_args()
25
+
26
+
27
+ def clamp_percent(value: float) -> int:
28
+ return int(round(max(0.0, min(100.0, value))))
29
+
30
+
31
+ def sanitize_field(value: str) -> str:
32
+ return value.replace("\t", " ").replace("\n", " ").strip()
33
+
34
+
35
+ def decode_jwt_payload(token: str) -> Dict[str, Any]:
36
+ if not token or "." not in token:
37
+ return {}
38
+ parts = token.split(".")
39
+ if len(parts) < 2:
40
+ return {}
41
+ payload = parts[1]
42
+ padding = "=" * ((4 - len(payload) % 4) % 4)
43
+ try:
44
+ raw = base64.urlsafe_b64decode(payload + padding)
45
+ decoded = json.loads(raw.decode("utf-8"))
46
+ if isinstance(decoded, dict):
47
+ return decoded
48
+ except Exception:
49
+ return {}
50
+ return {}
51
+
52
+
53
+ def parse_timestamp(value: Any) -> Optional[int]:
54
+ if value is None:
55
+ return None
56
+ if isinstance(value, bool):
57
+ return None
58
+ if isinstance(value, (int, float)):
59
+ timestamp = int(value)
60
+ if timestamp > 10_000_000_000:
61
+ timestamp = int(timestamp / 1000)
62
+ return timestamp
63
+ if isinstance(value, str):
64
+ s = value.strip()
65
+ if not s:
66
+ return None
67
+ if s.isdigit():
68
+ return parse_timestamp(int(s))
69
+ if s.endswith("Z"):
70
+ s = s[:-1] + "+00:00"
71
+ try:
72
+ parsed = dt.datetime.fromisoformat(s)
73
+ return int(parsed.timestamp())
74
+ except Exception:
75
+ return None
76
+ return None
77
+
78
+
79
+ def normalize_plan(plan: Any) -> str:
80
+ if not isinstance(plan, str):
81
+ return "unknown"
82
+ value = plan.strip().lower()
83
+ if not value:
84
+ return "unknown"
85
+ if value in KNOWN_PLANS:
86
+ return value
87
+ if value.startswith("chatgpt_"):
88
+ trimmed = value[len("chatgpt_") :]
89
+ if trimmed in KNOWN_PLANS:
90
+ return trimmed
91
+ return "unknown"
92
+
93
+
94
+ def load_json(path: str) -> Dict[str, Any]:
95
+ try:
96
+ with open(path, "r", encoding="utf-8") as f:
97
+ obj = json.load(f)
98
+ if isinstance(obj, dict):
99
+ return obj
100
+ except Exception:
101
+ return {}
102
+ return {}
103
+
104
+
105
+ def parse_window(window: Any) -> Optional[Dict[str, Any]]:
106
+ if not isinstance(window, dict):
107
+ return None
108
+
109
+ used = window.get("used_percent")
110
+ if not isinstance(used, (int, float)):
111
+ return None
112
+
113
+ minutes = window.get("window_minutes")
114
+ if not isinstance(minutes, (int, float)):
115
+ seconds = window.get("limit_window_seconds")
116
+ if isinstance(seconds, (int, float)) and seconds > 0:
117
+ minutes = int(round(float(seconds) / 60.0))
118
+ if not isinstance(minutes, (int, float)):
119
+ return None
120
+
121
+ reset_epoch = parse_timestamp(
122
+ window.get("resets_at")
123
+ if "resets_at" in window
124
+ else window.get("reset_at")
125
+ )
126
+ return {
127
+ "minutes": int(minutes),
128
+ "remaining_percent": 100.0 - float(used),
129
+ "reset_epoch": reset_epoch,
130
+ }
131
+
132
+
133
+ def pick_window(windows: Dict[int, Dict[str, Any]], target: int) -> Optional[Dict[str, Any]]:
134
+ if target in windows:
135
+ return windows[target]
136
+ if not windows:
137
+ return None
138
+ nearest = min(windows.keys(), key=lambda m: abs(m - target))
139
+ if target == 300 and abs(nearest - target) <= 30:
140
+ return windows[nearest]
141
+ if target == 10080 and abs(nearest - target) <= 720:
142
+ return windows[nearest]
143
+ return None
144
+
145
+
146
+ def extract_windows_from_usage_blob(blob: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
147
+ windows: Dict[int, Dict[str, Any]] = {}
148
+ candidates = []
149
+ for key in ("rate_limit", "rate_limits"):
150
+ value = blob.get(key)
151
+ if isinstance(value, dict):
152
+ candidates.append(value)
153
+ candidates.append(blob)
154
+
155
+ for container in candidates:
156
+ for key in ("primary_window", "secondary_window", "primary", "secondary"):
157
+ parsed = parse_window(container.get(key))
158
+ if parsed:
159
+ windows[parsed["minutes"]] = parsed
160
+ if isinstance(container.get("windows"), list):
161
+ for item in container["windows"]:
162
+ parsed = parse_window(item)
163
+ if parsed:
164
+ windows[parsed["minutes"]] = parsed
165
+ return windows
166
+
167
+
168
+ def request_usage(access_token: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
169
+ if not access_token:
170
+ return None
171
+ cmd = [
172
+ "curl",
173
+ "-fsS",
174
+ "--connect-timeout",
175
+ str(max(1, timeout_seconds)),
176
+ "--max-time",
177
+ str(max(2, timeout_seconds + 2)),
178
+ "-H",
179
+ f"Authorization: Bearer {access_token}",
180
+ USAGE_ENDPOINT,
181
+ ]
182
+ try:
183
+ result = subprocess.run(
184
+ cmd,
185
+ check=False,
186
+ stdout=subprocess.PIPE,
187
+ stderr=subprocess.PIPE,
188
+ text=True,
189
+ )
190
+ except Exception:
191
+ return None
192
+ if result.returncode != 0 or not result.stdout.strip():
193
+ return None
194
+ try:
195
+ payload = json.loads(result.stdout)
196
+ if isinstance(payload, dict):
197
+ return payload
198
+ except Exception:
199
+ return None
200
+ return None
201
+
202
+
203
+ def format_usage(window: Optional[Dict[str, Any]]) -> str:
204
+ if not window:
205
+ return DASH
206
+ percent = clamp_percent(float(window["remaining_percent"]))
207
+ reset_epoch = window.get("reset_epoch")
208
+ if isinstance(reset_epoch, int):
209
+ reset_text = dt.datetime.fromtimestamp(reset_epoch).strftime("%H:%M")
210
+ return f"{percent}% ({reset_text})"
211
+ return f"{percent}%"
212
+
213
+
214
+ def format_relative(last_epoch: Optional[int], now_epoch: int) -> str:
215
+ if not isinstance(last_epoch, int):
216
+ return DASH
217
+ delta = max(0, now_epoch - last_epoch)
218
+ if delta < 60:
219
+ return "just now"
220
+ if delta < 3600:
221
+ return f"{delta // 60}m ago"
222
+ if delta < 86400:
223
+ return f"{delta // 3600}h ago"
224
+ return f"{delta // 86400}d ago"
225
+
226
+
227
+ def collect_local_metrics(data_path: str) -> Dict[str, Any]:
228
+ sessions_dir = os.path.join(data_path, "sessions")
229
+ if not os.path.isdir(sessions_dir):
230
+ return {"source": "local"}
231
+
232
+ best: Optional[Dict[str, Any]] = None
233
+ for root, _, files in os.walk(sessions_dir):
234
+ for file_name in files:
235
+ if not file_name.endswith(".jsonl"):
236
+ continue
237
+ file_path = os.path.join(root, file_name)
238
+ try:
239
+ with open(file_path, "r", encoding="utf-8") as f:
240
+ for line in f:
241
+ if '"rate_limits"' not in line:
242
+ continue
243
+ obj = json.loads(line)
244
+ if not isinstance(obj, dict):
245
+ continue
246
+ payload = obj.get("payload")
247
+ if not isinstance(payload, dict):
248
+ continue
249
+ rate_limits = payload.get("rate_limits")
250
+ if not isinstance(rate_limits, dict):
251
+ continue
252
+
253
+ windows = extract_windows_from_usage_blob({"rate_limits": rate_limits})
254
+ if not windows:
255
+ continue
256
+
257
+ timestamp = parse_timestamp(obj.get("timestamp")) or parse_timestamp(payload.get("timestamp"))
258
+ plan = normalize_plan(rate_limits.get("plan_type"))
259
+ candidate = {
260
+ "windows": windows,
261
+ "plan_type": plan,
262
+ "last_activity_epoch": timestamp,
263
+ "source": "local",
264
+ }
265
+ if best is None:
266
+ best = candidate
267
+ else:
268
+ prev_ts = best.get("last_activity_epoch")
269
+ if isinstance(timestamp, int) and (not isinstance(prev_ts, int) or timestamp > prev_ts):
270
+ best = candidate
271
+ except Exception:
272
+ continue
273
+
274
+ if best:
275
+ return best
276
+ return {"source": "local"}
277
+
278
+
279
+ def collect_api_metrics(access_token: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
280
+ payload = request_usage(access_token, timeout_seconds)
281
+ if not payload:
282
+ return None
283
+
284
+ windows = extract_windows_from_usage_blob(payload)
285
+ nested_plan = None
286
+ for key in ("rate_limit", "rate_limits"):
287
+ value = payload.get(key)
288
+ if isinstance(value, dict):
289
+ nested_plan = value.get("plan_type")
290
+ if nested_plan:
291
+ break
292
+ plan_type = normalize_plan(payload.get("plan_type") or nested_plan)
293
+ last_activity = parse_timestamp(
294
+ payload.get("last_activity_at")
295
+ or payload.get("last_activity")
296
+ or payload.get("updated_at")
297
+ or payload.get("timestamp")
298
+ )
299
+
300
+ if not windows and plan_type == "unknown" and last_activity is None:
301
+ return None
302
+ return {
303
+ "windows": windows,
304
+ "plan_type": plan_type,
305
+ "last_activity_epoch": last_activity,
306
+ "source": "api",
307
+ }
308
+
309
+
310
+ def main() -> int:
311
+ args = parse_args()
312
+ now_epoch = int(time.time())
313
+ auth_data = load_json(args.auth_file)
314
+ tokens = auth_data.get("tokens")
315
+ if not isinstance(tokens, dict):
316
+ tokens = {}
317
+
318
+ access_token = tokens.get("access_token")
319
+ if not isinstance(access_token, str):
320
+ access_token = ""
321
+ id_token = tokens.get("id_token")
322
+ if not isinstance(id_token, str):
323
+ id_token = ""
324
+
325
+ claims = decode_jwt_payload(id_token)
326
+ email = claims.get("email")
327
+ if not isinstance(email, str):
328
+ email = ""
329
+
330
+ plan_from_claims = normalize_plan(claims.get("chatgpt_plan_type") or claims.get("plan_type"))
331
+ if plan_from_claims == "unknown":
332
+ plan_from_claims = normalize_plan(auth_data.get("chatgpt_plan_type") or auth_data.get("plan_type"))
333
+
334
+ api_metrics = collect_api_metrics(access_token, args.timeout_seconds)
335
+ metrics = api_metrics if api_metrics is not None else collect_local_metrics(args.data_path)
336
+
337
+ windows = metrics.get("windows")
338
+ if not isinstance(windows, dict):
339
+ windows = {}
340
+ window_5h = pick_window(windows, 300)
341
+ window_week = pick_window(windows, 10080)
342
+
343
+ usage_5h = format_usage(window_5h)
344
+ usage_weekly = format_usage(window_week)
345
+ plan = normalize_plan(metrics.get("plan_type"))
346
+ if plan == "unknown":
347
+ plan = plan_from_claims
348
+ if plan == "unknown":
349
+ plan = "unknown"
350
+
351
+ source = metrics.get("source")
352
+ if source not in ("api", "local"):
353
+ source = "local"
354
+
355
+ last_activity_epoch = metrics.get("last_activity_epoch")
356
+ if not isinstance(last_activity_epoch, int):
357
+ last_activity_epoch = None
358
+ last_activity = format_relative(last_activity_epoch, now_epoch)
359
+
360
+ if email:
361
+ display_email = f"({args.account_name}){email}"
362
+ else:
363
+ display_email = f"({args.account_name})-"
364
+
365
+ out_fields = [
366
+ display_email,
367
+ plan,
368
+ usage_5h,
369
+ usage_weekly,
370
+ last_activity,
371
+ source,
372
+ ]
373
+ print("\t".join(sanitize_field(str(x)) for x in out_fields))
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ sys.exit(main())
@@ -8,9 +8,10 @@ bash -n "$SW"
8
8
 
9
9
  TMPBASE="$(mktemp -d /tmp/codex-switcher-test.XXXXXX)"
10
10
  STATE="$TMPBASE/state"
11
- PROFILES="$TMPBASE/profiles"
11
+ ENVS="$TMPBASE/envs"
12
+ DEFAULT_HOME="$TMPBASE/default-home"
12
13
  BIN="$TMPBASE/bin"
13
- mkdir -p "$BIN"
14
+ mkdir -p "$BIN" "$DEFAULT_HOME"
14
15
 
15
16
  cleanup() {
16
17
  pkill -f "$BIN/fake-codex-app" >/dev/null 2>&1 || true
@@ -32,7 +33,7 @@ if [[ "${1:-}" == "login" && "${2:-}" == "status" ]]; then
32
33
  fi
33
34
  if [[ "${1:-}" == "login" ]]; then
34
35
  mkdir -p "$CODEX_HOME"
35
- echo '{"auth_mode":"api_key"}' > "$CODEX_HOME/auth.json"
36
+ echo '{"auth_mode":"chatgpt","tokens":{"access_token":"fake-access","id_token":"fake.jwt.sig"}}' > "$CODEX_HOME/auth.json"
36
37
  exit 0
37
38
  fi
38
39
  if [[ "${1:-}" == "logout" ]]; then
@@ -58,112 +59,127 @@ exit 0
58
59
  NPM
59
60
  chmod +x "$BIN/npm"
60
61
 
62
+ cat > "$BIN/curl" <<'CURL'
63
+ #!/usr/bin/env bash
64
+ set -euo pipefail
65
+ mode="${CODEX_SWITCHER_TEST_CURL_MODE:-success}"
66
+ if [[ "$mode" == "success" ]]; then
67
+ cat <<'JSON'
68
+ {"rate_limit":{"plan_type":"plus","primary_window":{"used_percent":40,"limit_window_seconds":18000,"reset_at":"2099-01-01T06:30:00Z"},"secondary_window":{"used_percent":20,"limit_window_seconds":604800,"reset_at":"2099-01-03T08:00:00Z"}},"last_activity_at":"2099-01-01T04:30:00Z"}
69
+ JSON
70
+ exit 0
71
+ fi
72
+ echo "simulated curl failure" >&2
73
+ exit 22
74
+ CURL
75
+ chmod +x "$BIN/curl"
76
+
61
77
  export PATH="$BIN:$PATH"
62
78
  export CODEX_SWITCHER_STATE_DIR="$STATE"
63
- export CODEX_SWITCHER_PROFILES_DIR="$PROFILES"
79
+ export CODEX_SWITCHER_ENVS_DIR="$ENVS"
80
+ export CODEX_SWITCHER_ACCOUNTS_DIR="$STATE/env-accounts"
64
81
  export CODEX_SWITCHER_APP_BIN="$BIN/fake-codex-app"
65
82
  export CODEX_SWITCHER_LOCK_WAIT_SECONDS=2
66
- export CODEX_SWITCHER_DEFAULT_HOME="$TMPBASE/default-home"
83
+ export CODEX_SWITCHER_DEFAULT_HOME="$DEFAULT_HOME"
67
84
  export CODEX_SWITCHER_TEST_NPM_LOG="$TMPBASE/npm-args.log"
68
85
  export CODEX_SWITCHER_TEST_CODEX_LOG="$TMPBASE/codex-args.log"
86
+ export CODEX_SWITCHER_TEST_CURL_MODE="success"
69
87
  : > "$CODEX_SWITCHER_TEST_CODEX_LOG"
70
88
 
71
- mkdir -p "$CODEX_SWITCHER_DEFAULT_HOME/memories"
72
- echo '{"auth_mode":"chatgpt"}' > "$CODEX_SWITCHER_DEFAULT_HOME/auth.json"
73
- echo '{"projects":["demo"]}' > "$CODEX_SWITCHER_DEFAULT_HOME/state_5.sqlite"
74
- echo '{"memo":"persist"}' > "$CODEX_SWITCHER_DEFAULT_HOME/memories/demo.json"
89
+ echo '{"memo":"persist"}' > "$DEFAULT_HOME/shared.json"
75
90
 
76
91
  check_out="$("$SW" check)"
92
+ echo "$check_out" | grep -Eq '^version: [0-9]+\.[0-9]+\.[0-9]+$'
77
93
  echo "$check_out" | grep -q "check: ok"
78
94
  init_out="$("$SW" init --dry-run)"
79
95
  echo "$init_out" | grep -q "\[dry-run\]"
80
96
  "$SW" upgrade
81
97
  grep -q "i -g @wangxt0223/codex-switcher@latest --registry https://registry.npmjs.org/" "$CODEX_SWITCHER_TEST_NPM_LOG"
82
98
 
83
- "$SW" add work
84
- "$SW" add personal
85
- codex_calls_before="$(wc -l < "$CODEX_SWITCHER_TEST_CODEX_LOG" 2>/dev/null || echo 0)"
86
- "$SW" use personal --no-launch
87
- codex_calls_after="$(wc -l < "$CODEX_SWITCHER_TEST_CODEX_LOG" 2>/dev/null || echo 0)"
88
- [[ "$codex_calls_before" -eq "$codex_calls_after" ]]
89
- [[ "$("$SW" current cli)" == "personal" ]]
90
-
91
- "$SW" use personal -- --version
92
- grep -Fq "$PROFILES/personal|--version" "$CODEX_SWITCHER_TEST_CODEX_LOG"
93
-
94
- set +e
95
- "$SW" use personal --no-launch -- --version >/tmp/codex_sw_use_no_launch_conflict 2>&1
96
- use_no_launch_conflict_rc=$?
97
- set -e
98
- [[ "$use_no_launch_conflict_rc" -ne 0 ]]
99
- grep -q "cannot pass codex args with --no-launch" /tmp/codex_sw_use_no_launch_conflict
100
-
101
- "$SW" login personal
102
- "$SW" login sync-login --sync
103
- [[ -f "$PROFILES/sync-login/state_5.sqlite" ]]
104
- [[ -f "$PROFILES/sync-login/memories/demo.json" ]]
105
- [[ -f "$PROFILES/sync-login/auth.json" ]]
106
-
107
- echo '{"auth_mode":"api_key","owner":"personal"}' > "$PROFILES/personal/auth.json"
108
- echo '{"auth_mode":"api_key","owner":"work"}' > "$PROFILES/work/auth.json"
109
- echo "from-personal-newer" > "$PROFILES/personal/history.jsonl"
110
- echo "from-work-older-baseline" > "$PROFILES/work/history.jsonl"
111
- "$SW" switch work --sync
112
- [[ "$("$SW" current cli)" == "work" ]]
113
- grep -q "from-personal-newer" "$PROFILES/work/history.jsonl"
114
- grep -q '"owner":"work"' "$PROFILES/work/auth.json"
115
- grep -q '"owner":"personal"' "$PROFILES/personal/auth.json"
116
-
117
- "$SW" import-default imported
118
- [[ -f "$PROFILES/imported/state_5.sqlite" ]]
119
- [[ -f "$PROFILES/imported/memories/demo.json" ]]
120
- [[ ! -f "$PROFILES/imported/auth.json" ]]
121
- set +e
122
- CODEX_HOME="$PROFILES/imported" codex login status >/tmp/codex_sw_imported_login 2>&1
123
- imported_login_rc=$?
124
- set -e
125
- [[ "$imported_login_rc" -ne 0 ]]
126
-
127
- "$SW" import-default imported-auth --with-auth
128
- [[ -f "$PROFILES/imported-auth/auth.json" ]]
129
- CODEX_HOME="$PROFILES/imported-auth" codex login status >/tmp/codex_sw_imported_auth_login 2>&1
130
- [[ "$?" -eq 0 ]]
131
-
132
- "$SW" logout work
133
-
134
- set +e
135
- "$SW" app open ghost >/tmp/codex_sw_app_open_missing 2>&1
136
- app_open_missing_rc=$?
137
- set -e
138
- [[ "$app_open_missing_rc" -ne 0 ]]
139
- grep -q "profile 'ghost' not found" /tmp/codex_sw_app_open_missing
140
-
141
- set +e
142
- "$SW" app use work >/tmp/codex_sw_app_use_unauthed 2>&1
143
- app_use_unauthed_rc=$?
144
- set -e
145
- [[ "$app_use_unauthed_rc" -ne 0 ]]
146
- grep -q "profile 'work' is not logged in" /tmp/codex_sw_app_use_unauthed
99
+ [[ "$("$SW" env current cli)" == "default" ]]
100
+ "$SW" account login personal --env default
101
+ "$SW" account login work --env default
102
+
103
+ make_id_token() {
104
+ python3 - "$1" "$2" <<'PY'
105
+ import base64, json, sys
106
+ email = sys.argv[1]
107
+ plan = sys.argv[2]
108
+ header = base64.urlsafe_b64encode(json.dumps({"alg":"none","typ":"JWT"}, separators=(",", ":")).encode()).decode().rstrip("=")
109
+ payload = base64.urlsafe_b64encode(json.dumps({"email":email,"chatgpt_plan_type":plan}, separators=(",", ":")).encode()).decode().rstrip("=")
110
+ print(f"{header}.{payload}.sig")
111
+ PY
112
+ }
147
113
 
148
- "$SW" login work
114
+ personal_id_token="$(make_id_token personal@example.com plus)"
115
+ work_id_token="$(make_id_token work@example.com team)"
116
+
117
+ cat > "$STATE/env-accounts/default/personal/auth.json" <<JSON
118
+ {"auth_mode":"chatgpt","tokens":{"access_token":"token-personal","id_token":"$personal_id_token"}}
119
+ JSON
120
+ cat > "$STATE/env-accounts/default/work/auth.json" <<JSON
121
+ {"auth_mode":"chatgpt","tokens":{"access_token":"token-work","id_token":"$work_id_token"}}
122
+ JSON
123
+
124
+ "$SW" account use personal --env default
125
+ grep -q "token-personal" "$DEFAULT_HOME/auth.json"
126
+ "$SW" account use work --env default
127
+ grep -q "token-work" "$DEFAULT_HOME/auth.json"
128
+ grep -q '{"memo":"persist"}' "$DEFAULT_HOME/shared.json"
129
+ "$SW" account use personal --env default --sync
130
+ grep -q '{"memo":"persist"}' "$DEFAULT_HOME/shared.json"
131
+
132
+ "$SW" env create project --empty
133
+ [[ -d "$ENVS/project/home" ]]
134
+ echo '{"shared":"project"}' > "$ENVS/project/home/shared.json"
135
+
136
+ corp_id_token="$(make_id_token corp@example.com business)"
137
+ dev_id_token="$(make_id_token dev@example.com pro)"
138
+ mkdir -p "$STATE/env-accounts/project/corp" "$STATE/env-accounts/project/dev"
139
+ cat > "$STATE/env-accounts/project/corp/auth.json" <<JSON
140
+ {"auth_mode":"chatgpt","tokens":{"access_token":"token-corp","id_token":"$corp_id_token"}}
141
+ JSON
142
+ cat > "$STATE/env-accounts/project/dev/auth.json" <<JSON
143
+ {"auth_mode":"chatgpt","tokens":{"access_token":"token-dev","id_token":"$dev_id_token"}}
144
+ JSON
145
+
146
+ "$SW" account use corp --env project
147
+ [[ "$("$SW" current cli)" == "project/corp" ]]
148
+ grep -q "token-corp" "$ENVS/project/home/auth.json"
149
+ "$SW" account use dev --env project
150
+ [[ "$("$SW" current cli)" == "project/dev" ]]
151
+ grep -q "token-dev" "$ENVS/project/home/auth.json"
152
+ grep -q '{"shared":"project"}' "$ENVS/project/home/shared.json"
153
+
154
+ "$SW" use corp --no-launch
155
+ [[ "$("$SW" current cli)" == "project/corp" ]]
156
+
157
+ "$SW" account use work --env default --target app
149
158
  "$SW" app use work
150
- [[ "$("$SW" app current)" == "work" ]]
151
-
152
- set +e
153
- "$SW" status >/tmp/codex_sw_status_1
154
- status_rc=$?
155
- set -e
156
- [[ "$status_rc" -eq 0 ]]
157
- grep -q "cli(work): logged-in" /tmp/codex_sw_status_1
158
- grep -q "app(work): logged-in" /tmp/codex_sw_status_1
159
-
160
- "$SW" logout work
161
- set +e
162
- "$SW" status >/tmp/codex_sw_status_2
163
- status_rc=$?
164
- set -e
165
- [[ "$status_rc" -eq 1 ]]
166
- grep -q "cli(work): not-logged-in" /tmp/codex_sw_status_2
159
+ [[ "$("$SW" app current)" == "default/work" ]]
160
+
161
+ "$SW" list >/tmp/codex_sw_list_api
162
+ grep -q "ENV" /tmp/codex_sw_list_api
163
+ grep -q "ACCOUNT" /tmp/codex_sw_list_api
164
+ grep -q "EMAIL" /tmp/codex_sw_list_api
165
+ grep -q "PLAN" /tmp/codex_sw_list_api
166
+ grep -q "5H USAGE" /tmp/codex_sw_list_api
167
+ grep -q "WEEKLY USAGE" /tmp/codex_sw_list_api
168
+ grep -q "LAST ACTIVITY" /tmp/codex_sw_list_api
169
+ grep -q "(personal)personal@example.com" /tmp/codex_sw_list_api
170
+ grep -q "(api)" /tmp/codex_sw_list_api
171
+ grep -q "60% (" /tmp/codex_sw_list_api
172
+ grep -q "80% (" /tmp/codex_sw_list_api
173
+
174
+ mkdir -p "$ENVS/project/home/sessions/2026/04/12"
175
+ cat > "$ENVS/project/home/sessions/2026/04/12/rollout-test.jsonl" <<'JSONL'
176
+ {"timestamp":"2026-04-12T09:00:00Z","type":"event_msg","payload":{"type":"token_count","rate_limits":{"plan_type":"business","primary":{"used_percent":25,"window_minutes":300,"resets_at":1776004200},"secondary":{"used_percent":70,"window_minutes":10080,"resets_at":1776519000}}}}
177
+ JSONL
178
+ export CODEX_SWITCHER_TEST_CURL_MODE="fail"
179
+ "$SW" list >/tmp/codex_sw_list_local
180
+ grep -q "(local)" /tmp/codex_sw_list_local
181
+ grep -q "75% (" /tmp/codex_sw_list_local
182
+ grep -q "30% (" /tmp/codex_sw_list_local
167
183
 
168
184
  "$SW" app status >/tmp/codex_sw_app_status_1
169
185
  [[ "$?" -eq 0 ]]
@@ -176,24 +192,7 @@ app_status_rc=$?
176
192
  set -e
177
193
  [[ "$app_status_rc" -eq 1 ]]
178
194
 
179
- printf '***bad***\n' > "$STATE/current_cli"
180
- set +e
181
- "$SW" status >/tmp/codex_sw_status_3
182
- status_rc=$?
183
- set -e
184
- [[ "$status_rc" -eq 2 ]]
185
-
186
- "$SW" recover
187
- validate_cli="$("$SW" current cli)"
188
- [[ -n "$validate_cli" ]]
189
-
190
195
  doctor_out="$("$SW" doctor --fix)"
191
196
  echo "$doctor_out" | grep -q "doctor --fix: completed"
192
- check_out="$("$SW" check)"
193
- echo "$check_out" | grep -q "check: ok"
194
-
195
- "$SW" remove work --force
196
- "$SW" list >/tmp/codex_sw_list
197
- grep -q "personal" /tmp/codex_sw_list
198
197
 
199
198
  echo "smoke-test: ok"