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