@wangxt0223/codex-switcher 0.4.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,1133 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ STATE_DIR="${CODEX_SWITCHER_STATE_DIR:-$HOME/.codex-switcher}"
5
+ PROFILES_DIR="${CODEX_SWITCHER_PROFILES_DIR:-$HOME/.codex-profiles}"
6
+ APP_LOG="${CODEX_SWITCHER_APP_LOG:-$STATE_DIR/app.log}"
7
+ SWITCH_LOG="${CODEX_SWITCHER_SWITCH_LOG:-$STATE_DIR/switcher.log}"
8
+ APP_PID_FILE="$STATE_DIR/app.pid"
9
+ LOCK_DIR="$STATE_DIR/.lock"
10
+ LOCK_WAIT_SECONDS="${CODEX_SWITCHER_LOCK_WAIT_SECONDS:-10}"
11
+
12
+ SCRIPT_NAME="${CODEX_SWITCHER_INVOKED_AS:-$(basename "$0")}"
13
+
14
+ usage() {
15
+ cat <<'USAGE'
16
+ Usage:
17
+ codex-sw add <profile>
18
+ codex-sw remove <profile> [--force]
19
+ codex-sw list
20
+ codex-sw import-default <profile> [--with-auth] [--force]
21
+ codex-sw use <profile> [--sync|--no-sync]
22
+ codex-sw switch <profile> [--sync|--no-sync]
23
+ codex-sw current [cli|app]
24
+ codex-sw status
25
+
26
+ codex-sw exec -- <codex args...>
27
+ codex-sw login [profile] [--sync|--no-sync]
28
+ codex-sw logout [profile]
29
+ codex-sw env [profile]
30
+
31
+ codex-sw app open [profile] [-- <app args...>]
32
+ codex-sw app use <profile> [-- <app args...>]
33
+ codex-sw app logout [profile]
34
+ codex-sw app status
35
+ codex-sw app stop
36
+ codex-sw app current
37
+
38
+ codex-sw init [--shell zsh|bash] [--dry-run]
39
+ codex-sw upgrade [--dry-run]
40
+ codex-sw recover [--dry-run]
41
+ codex-sw check
42
+ codex-sw doctor [--fix]
43
+
44
+ Compatibility:
45
+ codex-switcher <same-commands>
46
+
47
+ Sync behavior:
48
+ --sync enable overwrite sync (excluding auth.json)
49
+ - login --sync: ~/.codex -> target profile
50
+ - use/switch --sync: current CLI profile -> target profile
51
+ --no-sync disable sync explicitly (default)
52
+
53
+ Environment overrides:
54
+ CODEX_SWITCHER_STATE_DIR
55
+ CODEX_SWITCHER_PROFILES_DIR
56
+ CODEX_SWITCHER_APP_BIN
57
+ CODEX_SWITCHER_APP_LOG
58
+ CODEX_SWITCHER_SWITCH_LOG
59
+ CODEX_SWITCHER_LOCK_WAIT_SECONDS
60
+ CODEX_SWITCHER_DEFAULT_HOME
61
+ CODEX_SWITCHER_UPGRADE_REGISTRY
62
+ CODEX_SWITCHER_NPM_PACKAGE
63
+ USAGE
64
+ }
65
+
66
+ err() {
67
+ echo "Error: $*" >&2
68
+ exit 1
69
+ }
70
+
71
+ warn() {
72
+ echo "Warning: $*" >&2
73
+ }
74
+
75
+ now_utc() {
76
+ date -u +"%Y-%m-%dT%H:%M:%SZ"
77
+ }
78
+
79
+ redact() {
80
+ sed -E \
81
+ -e 's/sk-[A-Za-z0-9_-]{8,}/sk-***REDACTED***/g' \
82
+ -e 's/(access_token|refresh_token|id_token)=([^[:space:]]+)/\1=REDACTED/g'
83
+ }
84
+
85
+ log_event() {
86
+ local level="$1"
87
+ shift
88
+ local message="$*"
89
+ mkdir -p "$STATE_DIR"
90
+ chmod 700 "$STATE_DIR" 2>/dev/null || true
91
+ printf '%s [%s] %s\n' "$(now_utc)" "$level" "$message" | redact >> "$SWITCH_LOG"
92
+ }
93
+
94
+ ensure_dirs() {
95
+ mkdir -p "$STATE_DIR" "$PROFILES_DIR"
96
+ chmod 700 "$STATE_DIR" "$PROFILES_DIR" 2>/dev/null || true
97
+ }
98
+
99
+ perm_of() {
100
+ local path="$1"
101
+ if stat -f '%Lp' "$path" >/dev/null 2>&1; then
102
+ stat -f '%Lp' "$path"
103
+ else
104
+ stat -c '%a' "$path"
105
+ fi
106
+ }
107
+
108
+ validate_profile_name_noexit() {
109
+ local name="${1:-}"
110
+ [[ -n "$name" ]] || return 1
111
+ [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || return 1
112
+ return 0
113
+ }
114
+
115
+ validate_profile_name() {
116
+ local name="${1:-}"
117
+ validate_profile_name_noexit "$name" || err "invalid profile '$name' (allowed: A-Z a-z 0-9 . _ -)"
118
+ }
119
+
120
+ default_profile_for() {
121
+ local target="$1"
122
+ if [[ "$target" == "cli" ]]; then
123
+ echo "default"
124
+ else
125
+ echo "app-default"
126
+ fi
127
+ }
128
+
129
+ profile_path() {
130
+ local name="$1"
131
+ echo "$PROFILES_DIR/$name"
132
+ }
133
+
134
+ ensure_profile() {
135
+ local name="$1"
136
+ local p
137
+ p="$(profile_path "$name")"
138
+ mkdir -p "$p"
139
+ chmod 700 "$p" 2>/dev/null || true
140
+ }
141
+
142
+ require_profile_exists() {
143
+ local name="$1"
144
+ local p
145
+ p="$(profile_path "$name")"
146
+ [[ -d "$p" ]] || err "profile '$name' not found. run: $SCRIPT_NAME add $name"
147
+ }
148
+
149
+ current_file() {
150
+ local target="$1"
151
+ echo "$STATE_DIR/current_${target}"
152
+ }
153
+
154
+ set_current() {
155
+ local target="$1"
156
+ local profile="$2"
157
+ local file tmp
158
+ file="$(current_file "$target")"
159
+ tmp="${file}.tmp"
160
+ printf '%s\n' "$profile" > "$tmp"
161
+ chmod 600 "$tmp" 2>/dev/null || true
162
+ mv "$tmp" "$file"
163
+ }
164
+
165
+ read_current() {
166
+ local target="$1"
167
+ local file value default
168
+ file="$(current_file "$target")"
169
+ default="$(default_profile_for "$target")"
170
+
171
+ if [[ ! -f "$file" ]]; then
172
+ echo "$default"
173
+ return 1
174
+ fi
175
+
176
+ value="$(head -n 1 "$file" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
177
+ if ! validate_profile_name_noexit "$value"; then
178
+ echo "$default"
179
+ return 2
180
+ fi
181
+
182
+ echo "$value"
183
+ return 0
184
+ }
185
+
186
+ first_profile_name() {
187
+ local first
188
+ first="$(find "$PROFILES_DIR" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | head -n 1)"
189
+ if [[ -n "$first" ]]; then
190
+ echo "$first"
191
+ return 0
192
+ fi
193
+ return 1
194
+ }
195
+
196
+ resolve_app_bin() {
197
+ if [[ -n "${CODEX_SWITCHER_APP_BIN:-}" ]]; then
198
+ [[ -x "$CODEX_SWITCHER_APP_BIN" ]] || err "CODEX_SWITCHER_APP_BIN is not executable: $CODEX_SWITCHER_APP_BIN"
199
+ echo "$CODEX_SWITCHER_APP_BIN"
200
+ return
201
+ fi
202
+
203
+ if [[ -x "/Applications/Codex.app/Contents/MacOS/Codex" ]]; then
204
+ echo "/Applications/Codex.app/Contents/MacOS/Codex"
205
+ return
206
+ fi
207
+ if [[ -x "$HOME/Applications/Codex.app/Contents/MacOS/Codex" ]]; then
208
+ echo "$HOME/Applications/Codex.app/Contents/MacOS/Codex"
209
+ return
210
+ fi
211
+ err "Codex.app binary not found. set CODEX_SWITCHER_APP_BIN manually"
212
+ }
213
+
214
+ acquire_lock() {
215
+ ensure_dirs
216
+ local start now lock_pid
217
+ start="$(date +%s)"
218
+
219
+ while true; do
220
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
221
+ printf '%s\n' "$$" > "$LOCK_DIR/pid"
222
+ chmod 700 "$LOCK_DIR" 2>/dev/null || true
223
+ trap 'release_lock' EXIT INT TERM
224
+ return 0
225
+ fi
226
+
227
+ lock_pid=""
228
+ if [[ -f "$LOCK_DIR/pid" ]]; then
229
+ lock_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
230
+ fi
231
+
232
+ if [[ -n "$lock_pid" ]] && ! kill -0 "$lock_pid" >/dev/null 2>&1; then
233
+ rm -rf "$LOCK_DIR" >/dev/null 2>&1 || true
234
+ continue
235
+ fi
236
+
237
+ now="$(date +%s)"
238
+ if (( now - start >= LOCK_WAIT_SECONDS )); then
239
+ err "failed to acquire lock within ${LOCK_WAIT_SECONDS}s"
240
+ fi
241
+ sleep 0.1
242
+ done
243
+ }
244
+
245
+ release_lock() {
246
+ if [[ -d "$LOCK_DIR" ]] && [[ -f "$LOCK_DIR/pid" ]]; then
247
+ local lock_pid
248
+ lock_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
249
+ if [[ "$lock_pid" == "$$" ]]; then
250
+ rm -rf "$LOCK_DIR" >/dev/null 2>&1 || true
251
+ fi
252
+ fi
253
+ }
254
+
255
+ with_lock() {
256
+ acquire_lock
257
+ "$@"
258
+ release_lock
259
+ trap - EXIT INT TERM
260
+ }
261
+
262
+ run_codex_for_profile() {
263
+ local profile="$1"
264
+ shift
265
+ require_profile_exists "$profile"
266
+ CODEX_HOME="$(profile_path "$profile")" command codex "$@"
267
+ }
268
+
269
+ login_state_for_profile() {
270
+ local profile="$1"
271
+ local p rc
272
+ p="$(profile_path "$profile")"
273
+ if [[ ! -d "$p" ]]; then
274
+ echo "missing-profile"
275
+ return
276
+ fi
277
+
278
+ rc=0
279
+ CODEX_HOME="$p" command codex login status >/dev/null 2>&1 || rc=$?
280
+ if [[ "$rc" -eq 0 ]]; then
281
+ echo "logged-in"
282
+ else
283
+ echo "not-logged-in"
284
+ fi
285
+ }
286
+
287
+ kill_tree() {
288
+ local pid="$1"
289
+ local child
290
+ if [[ -z "$pid" ]]; then
291
+ return 0
292
+ fi
293
+ for child in $(pgrep -P "$pid" 2>/dev/null || true); do
294
+ kill_tree "$child"
295
+ done
296
+ kill "$pid" >/dev/null 2>&1 || true
297
+ }
298
+
299
+ app_is_running() {
300
+ if [[ ! -f "$APP_PID_FILE" ]]; then
301
+ return 1
302
+ fi
303
+ local pid
304
+ pid="$(cat "$APP_PID_FILE" 2>/dev/null || true)"
305
+ [[ -n "$pid" ]] || return 1
306
+ kill -0 "$pid" >/dev/null 2>&1
307
+ }
308
+
309
+ app_stop_managed() {
310
+ if [[ ! -f "$APP_PID_FILE" ]]; then
311
+ echo "No managed app process to stop"
312
+ return 0
313
+ fi
314
+
315
+ local pid
316
+ pid="$(cat "$APP_PID_FILE" 2>/dev/null || true)"
317
+ if [[ -z "$pid" ]]; then
318
+ rm -f "$APP_PID_FILE"
319
+ echo "No managed app process to stop"
320
+ return 0
321
+ fi
322
+
323
+ if kill -0 "$pid" >/dev/null 2>&1; then
324
+ log_event INFO "app_stop pid=$pid"
325
+ kill_tree "$pid"
326
+ sleep 0.3
327
+ fi
328
+
329
+ rm -f "$APP_PID_FILE"
330
+ echo "Stopped managed app process"
331
+ }
332
+
333
+ prompt_confirm_remove() {
334
+ local profile="$1"
335
+ if [[ ! -t 0 ]]; then
336
+ err "non-interactive terminal; rerun with --force"
337
+ fi
338
+ local answer
339
+ printf "Remove profile '%s'? This deletes %s [y/N]: " "$profile" "$(profile_path "$profile")" >&2
340
+ read -r answer
341
+ case "$answer" in
342
+ y|Y|yes|YES)
343
+ return 0
344
+ ;;
345
+ *)
346
+ echo "Cancelled"
347
+ return 1
348
+ ;;
349
+ esac
350
+ }
351
+
352
+ cmd_add() {
353
+ local profile="$1"
354
+ validate_profile_name "$profile"
355
+ ensure_profile "$profile"
356
+ log_event INFO "profile_add profile=$profile"
357
+ echo "Added profile: $profile"
358
+ }
359
+
360
+ cmd_remove() {
361
+ local profile="$1"
362
+ local force="${2:-false}"
363
+ local cli_cur app_cur
364
+
365
+ validate_profile_name "$profile"
366
+ require_profile_exists "$profile"
367
+
368
+ cli_cur="$(read_current cli || true)"
369
+ app_cur="$(read_current app || true)"
370
+
371
+ if [[ "$profile" == "$cli_cur" && "$force" != "true" ]]; then
372
+ err "profile '$profile' is current CLI profile; use --force to remove"
373
+ fi
374
+ if [[ "$profile" == "$app_cur" && "$force" != "true" ]]; then
375
+ err "profile '$profile' is current App profile; use --force to remove"
376
+ fi
377
+
378
+ if [[ "$force" != "true" ]]; then
379
+ prompt_confirm_remove "$profile" || return 0
380
+ fi
381
+
382
+ if [[ "$profile" == "$app_cur" ]]; then
383
+ app_stop_managed >/dev/null
384
+ set_current app "$(default_profile_for app)"
385
+ fi
386
+ if [[ "$profile" == "$cli_cur" ]]; then
387
+ set_current cli "$(default_profile_for cli)"
388
+ fi
389
+
390
+ rm -rf -- "$(profile_path "$profile")"
391
+ log_event INFO "profile_remove profile=$profile force=$force"
392
+ echo "Removed profile: $profile"
393
+ }
394
+
395
+ cmd_list() {
396
+ local cli_cur app_cur
397
+ cli_cur="$(read_current cli || true)"
398
+ app_cur="$(read_current app || true)"
399
+
400
+ if ! find "$PROFILES_DIR" -mindepth 1 -maxdepth 1 -type d | grep -q .; then
401
+ echo "No profiles yet. Use: $SCRIPT_NAME add <profile>"
402
+ return 0
403
+ fi
404
+
405
+ find "$PROFILES_DIR" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | while IFS= read -r name; do
406
+ local marks
407
+ marks=""
408
+ [[ "$name" == "$cli_cur" ]] && marks="${marks} [cli-current]"
409
+ [[ "$name" == "$app_cur" ]] && marks="${marks} [app-current]"
410
+ echo "- $name$marks"
411
+ done
412
+ }
413
+
414
+ cmd_import_default() {
415
+ local profile="$1"
416
+ local with_auth="$2"
417
+ local force="$3"
418
+ local src dst
419
+
420
+ src="${CODEX_SWITCHER_DEFAULT_HOME:-$HOME/.codex}"
421
+ validate_profile_name "$profile"
422
+ [[ -d "$src" ]] || err "default codex home not found: $src"
423
+ ensure_profile "$profile"
424
+ dst="$(profile_path "$profile")"
425
+
426
+ if [[ "$force" == "true" ]]; then
427
+ find "$dst" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null || true
428
+ else
429
+ if find "$dst" -mindepth 1 -maxdepth 1 | grep -q .; then
430
+ err "profile '$profile' is not empty. use --force to overwrite"
431
+ fi
432
+ fi
433
+
434
+ if command -v rsync >/dev/null 2>&1; then
435
+ if [[ "$with_auth" == "true" ]]; then
436
+ rsync -a "$src/" "$dst/"
437
+ else
438
+ rsync -a --exclude 'auth.json' "$src/" "$dst/"
439
+ fi
440
+ else
441
+ if [[ "$with_auth" == "true" ]]; then
442
+ (cd "$src" && tar cf - .) | (cd "$dst" && tar xpf -)
443
+ else
444
+ (cd "$src" && tar cf - --exclude ./auth.json .) | (cd "$dst" && tar xpf -)
445
+ fi
446
+ fi
447
+
448
+ chmod 700 "$dst" 2>/dev/null || true
449
+ log_event INFO "import_default profile=$profile src=$src with_auth=$with_auth force=$force"
450
+ echo "Imported default data to profile: $profile"
451
+ if [[ "$with_auth" != "true" ]]; then
452
+ echo "Auth not imported. Run: $SCRIPT_NAME login $profile"
453
+ fi
454
+ }
455
+
456
+ sync_overwrite_excluding_auth() {
457
+ local src="$1"
458
+ local dst="$2"
459
+
460
+ [[ -d "$src" ]] || err "sync source not found: $src"
461
+ mkdir -p "$dst"
462
+
463
+ if command -v rsync >/dev/null 2>&1; then
464
+ rsync -a --checksum --delete --exclude 'auth.json' "$src/" "$dst/"
465
+ else
466
+ find "$dst" -mindepth 1 -maxdepth 1 ! -name 'auth.json' -exec rm -rf -- {} + 2>/dev/null || true
467
+ (cd "$src" && tar cf - --exclude ./auth.json .) | (cd "$dst" && tar xpf -)
468
+ fi
469
+ chmod 700 "$dst" 2>/dev/null || true
470
+ }
471
+
472
+ sync_default_to_profile() {
473
+ local profile="$1"
474
+ local src dst
475
+ src="${CODEX_SWITCHER_DEFAULT_HOME:-$HOME/.codex}"
476
+ dst="$(profile_path "$profile")"
477
+ [[ -d "$src" ]] || err "default codex home not found: $src"
478
+ ensure_profile "$profile"
479
+ sync_overwrite_excluding_auth "$src" "$dst"
480
+ log_event INFO "sync_default profile=$profile src=$src"
481
+ echo "Synced default data to profile: $profile (auth.json excluded)"
482
+ }
483
+
484
+ sync_profile_to_profile() {
485
+ local from_profile="$1"
486
+ local to_profile="$2"
487
+ local src dst
488
+
489
+ if [[ "$from_profile" == "$to_profile" ]]; then
490
+ return 0
491
+ fi
492
+
493
+ src="$(profile_path "$from_profile")"
494
+ dst="$(profile_path "$to_profile")"
495
+ require_profile_exists "$from_profile"
496
+ ensure_profile "$to_profile"
497
+ sync_overwrite_excluding_auth "$src" "$dst"
498
+ log_event INFO "sync_profile from=$from_profile to=$to_profile"
499
+ echo "Synced profile data: $from_profile -> $to_profile (auth.json excluded)"
500
+ }
501
+
502
+ cmd_use() {
503
+ local profile="$1"
504
+ local sync_mode="${2:-false}"
505
+ local from_profile
506
+ validate_profile_name "$profile"
507
+
508
+ if [[ "$sync_mode" == "true" ]]; then
509
+ from_profile="$(read_current cli || true)"
510
+ validate_profile_name "$from_profile"
511
+ sync_profile_to_profile "$from_profile" "$profile"
512
+ fi
513
+
514
+ ensure_profile "$profile"
515
+ set_current cli "$profile"
516
+ log_event INFO "cli_use profile=$profile sync=$sync_mode"
517
+ echo "Switched CLI profile to: $profile"
518
+ echo "Run in current shell if needed:"
519
+ echo " export CODEX_HOME='$(profile_path "$profile")'"
520
+ }
521
+
522
+ cmd_switch() {
523
+ cmd_use "$1" "${2:-false}"
524
+ }
525
+
526
+ cmd_current() {
527
+ local target="${1:-all}"
528
+ case "$target" in
529
+ cli)
530
+ read_current cli || true
531
+ ;;
532
+ app)
533
+ read_current app || true
534
+ ;;
535
+ all)
536
+ echo "cli: $(read_current cli || true)"
537
+ echo "app: $(read_current app || true)"
538
+ ;;
539
+ *)
540
+ err "invalid target '$target' (use cli|app)"
541
+ ;;
542
+ esac
543
+ }
544
+
545
+ cmd_status() {
546
+ local cli_profile app_profile
547
+ local cli_ptr_state app_ptr_state
548
+ local cli_state app_state
549
+ local exit_code
550
+
551
+ cli_profile="$(read_current cli)" || cli_ptr_state=$?
552
+ app_profile="$(read_current app)" || app_ptr_state=$?
553
+ cli_ptr_state="${cli_ptr_state:-0}"
554
+ app_ptr_state="${app_ptr_state:-0}"
555
+
556
+ cli_state="$(login_state_for_profile "$cli_profile")"
557
+ app_state="$(login_state_for_profile "$app_profile")"
558
+
559
+ echo "cli_current: $cli_profile"
560
+ echo "app_current: $app_profile"
561
+ echo "cli($cli_profile): $cli_state"
562
+ echo "app($app_profile): $app_state"
563
+
564
+ exit_code=0
565
+ if [[ "$cli_ptr_state" -eq 2 || "$app_ptr_state" -eq 2 ]]; then
566
+ echo "hint: pointer file corrupted, run: $SCRIPT_NAME recover" >&2
567
+ exit_code=2
568
+ elif [[ "$cli_state" == "missing-profile" || "$app_state" == "missing-profile" ]]; then
569
+ echo "hint: missing profile directory, run: $SCRIPT_NAME recover" >&2
570
+ exit_code=2
571
+ elif [[ "$cli_state" != "logged-in" || "$app_state" != "logged-in" ]]; then
572
+ exit_code=1
573
+ fi
574
+
575
+ return "$exit_code"
576
+ }
577
+
578
+ cmd_exec() {
579
+ [[ "$#" -gt 0 ]] || err "missing codex args. example: $SCRIPT_NAME exec -- login status"
580
+ if [[ "$1" == "--" ]]; then
581
+ shift
582
+ fi
583
+ [[ "$#" -gt 0 ]] || err "missing codex args after --"
584
+
585
+ local profile
586
+ profile="$(read_current cli || true)"
587
+ ensure_profile "$profile"
588
+ CODEX_HOME="$(profile_path "$profile")" command codex "$@"
589
+ }
590
+
591
+ cmd_login() {
592
+ local profile="${1:-$(read_current cli || true)}"
593
+ local sync_mode="${2:-false}"
594
+ validate_profile_name "$profile"
595
+ ensure_profile "$profile"
596
+ if [[ "$sync_mode" == "true" ]]; then
597
+ sync_default_to_profile "$profile"
598
+ fi
599
+ log_event INFO "cli_login profile=$profile sync=$sync_mode"
600
+ run_codex_for_profile "$profile" login
601
+ }
602
+
603
+ cmd_logout() {
604
+ local profile="${1:-$(read_current cli || true)}"
605
+ validate_profile_name "$profile"
606
+ ensure_profile "$profile"
607
+ log_event INFO "cli_logout profile=$profile"
608
+ run_codex_for_profile "$profile" logout
609
+ }
610
+
611
+ cmd_env() {
612
+ local profile="${1:-$(read_current cli || true)}"
613
+ validate_profile_name "$profile"
614
+ ensure_profile "$profile"
615
+ echo "export CODEX_HOME='$(profile_path "$profile")'"
616
+ }
617
+
618
+ cmd_app_open() {
619
+ local profile="$1"
620
+ shift
621
+ local app_bin pid login_state
622
+
623
+ validate_profile_name "$profile"
624
+ require_profile_exists "$profile"
625
+ login_state="$(login_state_for_profile "$profile")"
626
+ if [[ "$login_state" != "logged-in" ]]; then
627
+ err "profile '$profile' is not logged in. run: $SCRIPT_NAME login $profile"
628
+ fi
629
+ app_bin="$(resolve_app_bin)"
630
+
631
+ app_stop_managed >/dev/null
632
+
633
+ mkdir -p "$(dirname "$APP_LOG")"
634
+ nohup env CODEX_HOME="$(profile_path "$profile")" CODEX_SWITCHER_MANAGED=1 CODEX_SWITCHER_PROFILE="$profile" "$app_bin" "$@" >>"$APP_LOG" 2>&1 &
635
+ pid="$!"
636
+ printf '%s\n' "$pid" > "$APP_PID_FILE"
637
+ chmod 600 "$APP_PID_FILE" 2>/dev/null || true
638
+
639
+ set_current app "$profile"
640
+ log_event INFO "app_open profile=$profile pid=$pid args=$*"
641
+ echo "Opened Codex App with profile: $profile"
642
+ echo "App log: $APP_LOG"
643
+ }
644
+
645
+ cmd_app_use() {
646
+ local profile="$1"
647
+ shift
648
+ cmd_app_open "$profile" "$@"
649
+ }
650
+
651
+ cmd_app_logout() {
652
+ local profile="${1:-$(read_current app || true)}"
653
+ validate_profile_name "$profile"
654
+ ensure_profile "$profile"
655
+ log_event INFO "app_logout profile=$profile"
656
+ run_codex_for_profile "$profile" logout
657
+ }
658
+
659
+ cmd_app_status() {
660
+ local profile
661
+ profile="$(read_current app || true)"
662
+ echo "app_current: $profile"
663
+ if app_is_running; then
664
+ echo "app_process: running(pid=$(cat "$APP_PID_FILE"))"
665
+ return 0
666
+ fi
667
+ echo "app_process: not-running"
668
+ return 1
669
+ }
670
+
671
+ cmd_app_stop() {
672
+ app_stop_managed
673
+ log_event INFO "app_stop"
674
+ }
675
+
676
+ cmd_init() {
677
+ local shell_name=""
678
+ local dry_run="false"
679
+ local script_abs target_link rc_file block_start block_end
680
+
681
+ while [[ "$#" -gt 0 ]]; do
682
+ case "$1" in
683
+ --shell)
684
+ shift
685
+ [[ "$#" -gt 0 ]] || err "missing shell after --shell"
686
+ shell_name="$1"
687
+ ;;
688
+ --dry-run)
689
+ dry_run="true"
690
+ ;;
691
+ *)
692
+ err "unknown init option: $1"
693
+ ;;
694
+ esac
695
+ shift
696
+ done
697
+
698
+ if [[ -z "$shell_name" ]]; then
699
+ shell_name="$(basename "${SHELL:-zsh}")"
700
+ fi
701
+ case "$shell_name" in
702
+ zsh)
703
+ rc_file="$HOME/.zshrc"
704
+ ;;
705
+ bash)
706
+ rc_file="$HOME/.bashrc"
707
+ ;;
708
+ *)
709
+ err "unsupported shell '$shell_name' (use zsh or bash)"
710
+ ;;
711
+ esac
712
+
713
+ script_abs="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
714
+ target_link="$HOME/.local/bin/codex-sw"
715
+ block_start="# >>> codex-sw init >>>"
716
+ block_end="# <<< codex-sw init <<<"
717
+
718
+ if [[ "$dry_run" == "true" ]]; then
719
+ echo "[dry-run] mkdir -p $HOME/.local/bin"
720
+ echo "[dry-run] ln -sf $script_abs $target_link"
721
+ echo "[dry-run] ensure PATH block in $rc_file"
722
+ return 0
723
+ fi
724
+
725
+ mkdir -p "$HOME/.local/bin"
726
+ ln -sf "$script_abs" "$target_link"
727
+
728
+ if [[ ! -f "$rc_file" ]]; then
729
+ touch "$rc_file"
730
+ fi
731
+
732
+ if ! grep -qF "$block_start" "$rc_file"; then
733
+ cat >> "$rc_file" <<RC
734
+ $block_start
735
+ export PATH="\$HOME/.local/bin:\$PATH"
736
+ $block_end
737
+ RC
738
+ fi
739
+
740
+ log_event INFO "init shell=$shell_name rc_file=$rc_file"
741
+ echo "Initialized codex-sw for $shell_name"
742
+ echo "Run: source $rc_file"
743
+ }
744
+
745
+ cmd_upgrade() {
746
+ local dry_run="${1:-false}"
747
+ local registry npm_package cmdline
748
+
749
+ command -v npm >/dev/null 2>&1 || err "npm command not found; cannot upgrade. install npm first"
750
+ registry="${CODEX_SWITCHER_UPGRADE_REGISTRY:-https://registry.npmjs.org/}"
751
+ npm_package="${CODEX_SWITCHER_NPM_PACKAGE:-@wangxt0223/codex-switcher}"
752
+ cmdline=(npm i -g "${npm_package}@latest" --registry "$registry")
753
+
754
+ if [[ "$dry_run" == "true" ]]; then
755
+ echo "[dry-run] ${cmdline[*]}"
756
+ return 0
757
+ fi
758
+
759
+ log_event INFO "upgrade package=$npm_package registry=$registry"
760
+ "${cmdline[@]}"
761
+ echo "Upgraded codex-sw to latest."
762
+ }
763
+
764
+ cmd_recover() {
765
+ local dry_run="${1:-false}"
766
+ local cli_profile app_profile cli_ptr_state app_ptr_state first
767
+
768
+ cli_profile="$(read_current cli)" || cli_ptr_state=$?
769
+ app_profile="$(read_current app)" || app_ptr_state=$?
770
+ cli_ptr_state="${cli_ptr_state:-0}"
771
+ app_ptr_state="${app_ptr_state:-0}"
772
+
773
+ if [[ ! -d "$(profile_path "$cli_profile")" ]]; then
774
+ if first="$(first_profile_name)"; then
775
+ cli_profile="$first"
776
+ else
777
+ cli_profile="$(default_profile_for cli)"
778
+ ensure_profile "$cli_profile"
779
+ fi
780
+ cli_ptr_state=3
781
+ fi
782
+
783
+ if [[ ! -d "$(profile_path "$app_profile")" ]]; then
784
+ if [[ -d "$(profile_path "$cli_profile")" ]]; then
785
+ app_profile="$cli_profile"
786
+ elif first="$(first_profile_name)"; then
787
+ app_profile="$first"
788
+ else
789
+ app_profile="$(default_profile_for app)"
790
+ ensure_profile "$app_profile"
791
+ fi
792
+ app_ptr_state=3
793
+ fi
794
+
795
+ if [[ "$dry_run" == "true" ]]; then
796
+ echo "recover(dry-run): cli=$cli_profile app=$app_profile"
797
+ return 0
798
+ fi
799
+
800
+ set_current cli "$cli_profile"
801
+ set_current app "$app_profile"
802
+ log_event INFO "recover cli=$cli_profile app=$app_profile"
803
+ echo "Recovered pointers: cli=$cli_profile app=$app_profile"
804
+ }
805
+
806
+ scan_logs_for_secrets() {
807
+ local found=0
808
+ if [[ -f "$SWITCH_LOG" ]] && grep -E 'sk-[A-Za-z0-9_-]{8,}|(access_token|refresh_token|id_token)=' "$SWITCH_LOG" >/dev/null 2>&1; then
809
+ echo "redaction_check: failed ($SWITCH_LOG contains potential secrets)" >&2
810
+ found=1
811
+ fi
812
+ if [[ -f "$APP_LOG" ]] && grep -E 'sk-[A-Za-z0-9_-]{8,}|(access_token|refresh_token|id_token)=' "$APP_LOG" >/dev/null 2>&1; then
813
+ echo "redaction_check: failed ($APP_LOG contains potential secrets)" >&2
814
+ found=1
815
+ fi
816
+ return "$found"
817
+ }
818
+
819
+ cmd_check() {
820
+ command -v codex >/dev/null 2>&1 || err "codex command not found in PATH"
821
+ resolve_app_bin >/dev/null
822
+
823
+ ensure_dirs
824
+ local pstate
825
+ read_current cli >/dev/null || pstate=$?
826
+ if [[ "${pstate:-0}" -eq 2 ]]; then
827
+ err "current_cli pointer corrupted; run: $SCRIPT_NAME recover"
828
+ fi
829
+ pstate=0
830
+ read_current app >/dev/null || pstate=$?
831
+ if [[ "$pstate" -eq 2 ]]; then
832
+ err "current_app pointer corrupted; run: $SCRIPT_NAME recover"
833
+ fi
834
+
835
+ if ! scan_logs_for_secrets; then
836
+ err "log redaction check failed"
837
+ fi
838
+
839
+ echo "check: ok"
840
+ }
841
+
842
+ cmd_doctor() {
843
+ local fix="${1:-false}"
844
+ local issues=0
845
+ local state_perm profiles_perm
846
+
847
+ ensure_dirs
848
+ state_perm="$(perm_of "$STATE_DIR")"
849
+ profiles_perm="$(perm_of "$PROFILES_DIR")"
850
+
851
+ echo "state_dir: $STATE_DIR (perm=$state_perm)"
852
+ echo "profiles_dir: $PROFILES_DIR (perm=$profiles_perm)"
853
+ echo "cli_current: $(read_current cli || true)"
854
+ echo "app_current: $(read_current app || true)"
855
+
856
+ if ! command -v codex >/dev/null 2>&1; then
857
+ echo "- codex in PATH: missing"
858
+ issues=1
859
+ else
860
+ echo "- codex in PATH: ok"
861
+ fi
862
+
863
+ if resolve_app_bin >/dev/null 2>&1; then
864
+ echo "- codex app binary: ok ($(resolve_app_bin))"
865
+ else
866
+ echo "- codex app binary: missing"
867
+ issues=1
868
+ fi
869
+
870
+ if [[ "$state_perm" != "700" ]]; then
871
+ echo "- state dir permission not 700"
872
+ issues=1
873
+ if [[ "$fix" == "true" ]]; then
874
+ chmod 700 "$STATE_DIR" || true
875
+ fi
876
+ fi
877
+
878
+ if [[ "$profiles_perm" != "700" ]]; then
879
+ echo "- profiles dir permission not 700"
880
+ issues=1
881
+ if [[ "$fix" == "true" ]]; then
882
+ chmod 700 "$PROFILES_DIR" || true
883
+ fi
884
+ fi
885
+
886
+ if ! scan_logs_for_secrets; then
887
+ issues=1
888
+ else
889
+ echo "- redaction check: ok"
890
+ fi
891
+
892
+ local pstate=0
893
+ read_current cli >/dev/null || pstate=$?
894
+ if [[ "$pstate" -eq 2 ]]; then
895
+ echo "- current_cli pointer corrupted"
896
+ issues=1
897
+ fi
898
+ pstate=0
899
+ read_current app >/dev/null || pstate=$?
900
+ if [[ "$pstate" -eq 2 ]]; then
901
+ echo "- current_app pointer corrupted"
902
+ issues=1
903
+ fi
904
+
905
+ if [[ "$fix" == "true" ]]; then
906
+ with_lock cmd_recover false
907
+ chmod 700 "$STATE_DIR" "$PROFILES_DIR" 2>/dev/null || true
908
+ find "$PROFILES_DIR" -mindepth 1 -maxdepth 1 -type d -exec chmod 700 {} \; 2>/dev/null || true
909
+ issues=0
910
+ if ! scan_logs_for_secrets; then
911
+ issues=1
912
+ fi
913
+ echo "doctor --fix: completed"
914
+ fi
915
+
916
+ if [[ "$issues" -eq 0 ]]; then
917
+ echo "doctor: ok"
918
+ return 0
919
+ fi
920
+
921
+ echo "doctor: issues found"
922
+ if [[ "$fix" != "true" ]]; then
923
+ echo "Run: $SCRIPT_NAME doctor --fix"
924
+ fi
925
+ return 1
926
+ }
927
+
928
+ main() {
929
+ ensure_dirs
930
+
931
+ local cmd="${1:-}"
932
+ [[ -n "$cmd" ]] || {
933
+ usage
934
+ exit 0
935
+ }
936
+ shift || true
937
+
938
+ case "$cmd" in
939
+ add)
940
+ [[ "$#" -eq 1 ]] || err "usage: $SCRIPT_NAME add <profile>"
941
+ with_lock cmd_add "$1"
942
+ ;;
943
+ remove)
944
+ [[ "$#" -ge 1 && "$#" -le 2 ]] || err "usage: $SCRIPT_NAME remove <profile> [--force]"
945
+ local force="false"
946
+ if [[ "${2:-}" == "--force" ]]; then
947
+ force="true"
948
+ elif [[ -n "${2:-}" ]]; then
949
+ err "unknown option: ${2:-}"
950
+ fi
951
+ with_lock cmd_remove "$1" "$force"
952
+ ;;
953
+ list)
954
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME list"
955
+ cmd_list
956
+ ;;
957
+ import-default)
958
+ [[ "$#" -ge 1 ]] || err "usage: $SCRIPT_NAME import-default <profile> [--with-auth] [--force]"
959
+ local profile="$1"
960
+ local with_auth="false"
961
+ local force="false"
962
+ shift
963
+ while [[ "$#" -gt 0 ]]; do
964
+ case "$1" in
965
+ --with-auth)
966
+ with_auth="true"
967
+ ;;
968
+ --force)
969
+ force="true"
970
+ ;;
971
+ *)
972
+ err "unknown option: $1"
973
+ ;;
974
+ esac
975
+ shift
976
+ done
977
+ with_lock cmd_import_default "$profile" "$with_auth" "$force"
978
+ ;;
979
+ use)
980
+ [[ "$#" -ge 1 && "$#" -le 2 ]] || err "usage: $SCRIPT_NAME use <profile> [--sync|--no-sync]"
981
+ local profile="$1"
982
+ local sync="false"
983
+ if [[ "${2:-}" == "--sync" ]]; then
984
+ sync="true"
985
+ elif [[ "${2:-}" == "--no-sync" || -z "${2:-}" ]]; then
986
+ :
987
+ else
988
+ err "unknown option: ${2:-}"
989
+ fi
990
+ with_lock cmd_use "$profile" "$sync"
991
+ ;;
992
+ switch)
993
+ [[ "$#" -ge 1 && "$#" -le 2 ]] || err "usage: $SCRIPT_NAME switch <profile> [--sync|--no-sync]"
994
+ local profile="$1"
995
+ local sync="false"
996
+ if [[ "${2:-}" == "--sync" ]]; then
997
+ sync="true"
998
+ elif [[ "${2:-}" == "--no-sync" || -z "${2:-}" ]]; then
999
+ :
1000
+ else
1001
+ err "unknown option: ${2:-}"
1002
+ fi
1003
+ with_lock cmd_switch "$profile" "$sync"
1004
+ ;;
1005
+ current)
1006
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME current [cli|app]"
1007
+ cmd_current "${1:-all}"
1008
+ ;;
1009
+ status)
1010
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME status"
1011
+ cmd_status
1012
+ ;;
1013
+ exec)
1014
+ cmd_exec "$@"
1015
+ ;;
1016
+ login)
1017
+ [[ "$#" -le 2 ]] || err "usage: $SCRIPT_NAME login [profile] [--sync|--no-sync]"
1018
+ local profile=""
1019
+ local sync="false"
1020
+ local arg
1021
+ for arg in "$@"; do
1022
+ case "$arg" in
1023
+ --sync)
1024
+ sync="true"
1025
+ ;;
1026
+ --no-sync)
1027
+ sync="false"
1028
+ ;;
1029
+ *)
1030
+ if [[ -z "$profile" ]]; then
1031
+ profile="$arg"
1032
+ else
1033
+ err "unknown option: $arg"
1034
+ fi
1035
+ ;;
1036
+ esac
1037
+ done
1038
+ with_lock cmd_login "${profile:-}" "$sync"
1039
+ ;;
1040
+ logout)
1041
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME logout [profile]"
1042
+ with_lock cmd_logout "${1:-}"
1043
+ ;;
1044
+ env)
1045
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME env [profile]"
1046
+ cmd_env "${1:-}"
1047
+ ;;
1048
+ app)
1049
+ local sub="${1:-}"
1050
+ [[ -n "$sub" ]] || err "usage: $SCRIPT_NAME app <open|use|logout|status|stop|current> ..."
1051
+ shift || true
1052
+ case "$sub" in
1053
+ open)
1054
+ local profile="${1:-$(read_current app || true)}"
1055
+ if [[ "$#" -gt 0 ]]; then shift; fi
1056
+ if [[ "${1:-}" == "--" ]]; then shift; fi
1057
+ with_lock cmd_app_open "$profile" "$@"
1058
+ ;;
1059
+ use)
1060
+ [[ "$#" -ge 1 ]] || err "usage: $SCRIPT_NAME app use <profile> [-- <app args...>]"
1061
+ local profile="$1"
1062
+ shift
1063
+ if [[ "${1:-}" == "--" ]]; then shift; fi
1064
+ with_lock cmd_app_use "$profile" "$@"
1065
+ ;;
1066
+ logout)
1067
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME app logout [profile]"
1068
+ with_lock cmd_app_logout "${1:-}"
1069
+ ;;
1070
+ status)
1071
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME app status"
1072
+ cmd_app_status
1073
+ ;;
1074
+ stop)
1075
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME app stop"
1076
+ with_lock cmd_app_stop
1077
+ ;;
1078
+ current)
1079
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME app current"
1080
+ cmd_current app
1081
+ ;;
1082
+ *)
1083
+ err "unknown app subcommand: $sub"
1084
+ ;;
1085
+ esac
1086
+ ;;
1087
+ init)
1088
+ cmd_init "$@"
1089
+ ;;
1090
+ upgrade)
1091
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME upgrade [--dry-run]"
1092
+ local dry="false"
1093
+ if [[ "${1:-}" == "--dry-run" ]]; then
1094
+ dry="true"
1095
+ elif [[ -n "${1:-}" ]]; then
1096
+ err "unknown option: ${1:-}"
1097
+ fi
1098
+ cmd_upgrade "$dry"
1099
+ ;;
1100
+ recover)
1101
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME recover [--dry-run]"
1102
+ local dry="false"
1103
+ if [[ "${1:-}" == "--dry-run" ]]; then
1104
+ dry="true"
1105
+ elif [[ -n "${1:-}" ]]; then
1106
+ err "unknown option: ${1:-}"
1107
+ fi
1108
+ with_lock cmd_recover "$dry"
1109
+ ;;
1110
+ check)
1111
+ [[ "$#" -eq 0 ]] || err "usage: $SCRIPT_NAME check"
1112
+ cmd_check
1113
+ ;;
1114
+ doctor)
1115
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME doctor [--fix]"
1116
+ local fix="false"
1117
+ if [[ "${1:-}" == "--fix" ]]; then
1118
+ fix="true"
1119
+ elif [[ -n "${1:-}" ]]; then
1120
+ err "unknown option: ${1:-}"
1121
+ fi
1122
+ cmd_doctor "$fix"
1123
+ ;;
1124
+ help|-h|--help)
1125
+ usage
1126
+ ;;
1127
+ *)
1128
+ err "unknown command: $cmd"
1129
+ ;;
1130
+ esac
1131
+ }
1132
+
1133
+ main "$@"