codex-profile 0.2.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,1080 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ PROGRAM="${0##*/}"
6
+ VERSION="0.2.0"
7
+ CODEX_APP="${CODEX_APP:-/Applications/Codex.app}"
8
+ CODEX_APP_BIN="${CODEX_APP_BIN:-$CODEX_APP/Contents/MacOS/Codex}"
9
+ CODEX_BUNDLED_CLI="${CODEX_BUNDLED_CLI:-$CODEX_APP/Contents/Resources/codex}"
10
+ CODEX_PROFILE_QUIT_ATTEMPTS="${CODEX_PROFILE_QUIT_ATTEMPTS:-30}"
11
+ CODEX_PROFILE_QUIT_SLEEP="${CODEX_PROFILE_QUIT_SLEEP:-0.5}"
12
+ CODEX_PROFILE_UPGRADE_REPO="${CODEX_PROFILE_UPGRADE_REPO:-https://github.com/Ducksss/codex-profiles.git}"
13
+ CODEX_PROFILE_UPGRADE_REF="${CODEX_PROFILE_UPGRADE_REF:-main}"
14
+
15
+ usage() {
16
+ cat <<EOF
17
+ $PROGRAM - run Codex with isolated CODEX_HOME profiles
18
+
19
+ Usage:
20
+ $PROGRAM app <profile> [workspace]
21
+ $PROGRAM cli <profile> [codex-args...]
22
+ $PROGRAM login <profile> [codex-login-args...]
23
+ $PROGRAM init <profile>
24
+ $PROGRAM remove <profile> [--yes]
25
+ $PROGRAM status [profile]
26
+ $PROGRAM status --json [profile]
27
+ $PROGRAM path <profile>
28
+ $PROGRAM logs <profile> [--path|--tail [lines]]
29
+ $PROGRAM clone-config <source-profile> <target-profile> [--force]
30
+ $PROGRAM list
31
+ $PROGRAM doctor [--json]
32
+ $PROGRAM completions <bash|zsh|fish>
33
+ $PROGRAM upgrade [--dry-run] [--prefix <path>] [--ref <git-ref>]
34
+ $PROGRAM version
35
+
36
+ Examples:
37
+ $PROGRAM login personal
38
+ $PROGRAM init work
39
+ $PROGRAM app personal ~/Dev/my-project
40
+ $PROGRAM cli personal exec "review this repo"
41
+ $PROGRAM status
42
+ $PROGRAM logs personal --tail 50
43
+ $PROGRAM clone-config personal work
44
+ $PROGRAM upgrade
45
+
46
+ Profiles:
47
+ default -> ~/.codex
48
+ any other name -> ~/.codex-<profile>
49
+
50
+ Environment:
51
+ CODEX_APP Override Codex.app path.
52
+ CODEX_APP_BIN Override Codex Desktop binary path.
53
+ CODEX_CLI Override Codex CLI binary path.
54
+ CODEX_PROFILE_UPGRADE_REPO Override upgrade repository.
55
+ CODEX_PROFILE_UPGRADE_REF Override upgrade git ref.
56
+ CODEX_PROFILE_UPGRADE_CACHE Override upgrade cache checkout.
57
+ CODEX_PROFILE_UPGRADE_PREFIX Override upgrade install prefix.
58
+ EOF
59
+ }
60
+
61
+ die() {
62
+ printf 'Error: %s\n' "$*" >&2
63
+ exit 1
64
+ }
65
+
66
+ note() {
67
+ printf '%s\n' "$*"
68
+ }
69
+
70
+ is_valid_profile_name() {
71
+ local profile="$1"
72
+
73
+ [[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
74
+ }
75
+
76
+ validate_profile() {
77
+ local profile="$1"
78
+
79
+ if ! is_valid_profile_name "$profile"; then
80
+ die "Invalid profile '$profile'. Use letters, numbers, dots, dashes, or underscores."
81
+ fi
82
+ }
83
+
84
+ codex_home_for_profile() {
85
+ local profile="$1"
86
+
87
+ validate_profile "$profile"
88
+
89
+ if [[ "$profile" == "default" ]]; then
90
+ printf '%s/.codex\n' "$HOME"
91
+ else
92
+ printf '%s/.codex-%s\n' "$HOME" "$profile"
93
+ fi
94
+ }
95
+
96
+ discovered_profile_is_managed() {
97
+ local profile="$1"
98
+ local dir="$2"
99
+ local expected
100
+
101
+ is_valid_profile_name "$profile" || return 1
102
+ expected="$(codex_home_for_profile "$profile")"
103
+ [[ "$expected" == "$dir" ]]
104
+ }
105
+
106
+ discover_profiles() {
107
+ local include_default="${1:-yes}"
108
+ local dir profile
109
+
110
+ if [[ "$include_default" == "yes" && -d "$HOME/.codex" ]]; then
111
+ printf 'default\n'
112
+ fi
113
+
114
+ for dir in "$HOME"/.codex-*; do
115
+ [[ -d "$dir" ]] || continue
116
+ profile="${dir##*/.codex-}"
117
+ discovered_profile_is_managed "$profile" "$dir" || continue
118
+ printf '%s\n' "$profile"
119
+ done
120
+ }
121
+
122
+ ensure_home() {
123
+ local codex_home="$1"
124
+
125
+ mkdir -p "$codex_home" || die "Cannot create directory: $codex_home"
126
+ [[ -d "$codex_home" ]] || die "Not a directory: $codex_home"
127
+ chmod 700 "$codex_home" || die "Cannot set private permissions on: $codex_home"
128
+ }
129
+
130
+ codex_desktop_running() {
131
+ pgrep -x Codex > /dev/null 2>&1 && return 0
132
+ pgrep -f "$CODEX_BUNDLED_CLI app-server" > /dev/null 2>&1 && return 0
133
+ return 1
134
+ }
135
+
136
+ codex_cli_error_message() {
137
+ if [[ -n "${CODEX_CLI:-}" ]]; then
138
+ printf 'CODEX_CLI is set but not executable: %s\n' "$CODEX_CLI"
139
+ return 0
140
+ fi
141
+
142
+ printf 'Codex CLI not found. Install Codex or set CODEX_CLI=/path/to/codex.\n'
143
+ }
144
+
145
+ try_find_codex_cli() {
146
+ if [[ -n "${CODEX_CLI:-}" ]]; then
147
+ [[ -x "$CODEX_CLI" ]] || return 1
148
+ printf '%s\n' "$CODEX_CLI"
149
+ return 0
150
+ fi
151
+
152
+ if command -v codex > /dev/null 2>&1; then
153
+ command -v codex
154
+ return 0
155
+ fi
156
+
157
+ if [[ -x "$CODEX_BUNDLED_CLI" ]]; then
158
+ printf '%s\n' "$CODEX_BUNDLED_CLI"
159
+ return 0
160
+ fi
161
+
162
+ return 1
163
+ }
164
+
165
+ find_codex_cli() {
166
+ local codex_cli
167
+
168
+ if codex_cli="$(try_find_codex_cli)"; then
169
+ printf '%s\n' "$codex_cli"
170
+ return 0
171
+ fi
172
+
173
+ die "$(codex_cli_error_message)"
174
+ }
175
+
176
+ quit_codex_app() {
177
+ local attempt
178
+
179
+ if ! codex_desktop_running; then
180
+ return 0
181
+ fi
182
+
183
+ note "Quitting existing Codex app..."
184
+ osascript -e 'tell application "Codex" to quit' > /dev/null 2>&1 || true
185
+
186
+ for ((attempt = 0; attempt < CODEX_PROFILE_QUIT_ATTEMPTS; attempt++)); do
187
+ if ! codex_desktop_running; then
188
+ return 0
189
+ fi
190
+ sleep "$CODEX_PROFILE_QUIT_SLEEP"
191
+ done
192
+
193
+ die "Codex or its app-server is still running. Quit Codex with Cmd+Q, then retry."
194
+ }
195
+
196
+ command_app() {
197
+ local profile="${1:-}"
198
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM app <profile> [workspace]"
199
+ shift || true
200
+
201
+ local workspace="${1:-$PWD}"
202
+ local codex_home
203
+
204
+ [[ -x "$CODEX_APP_BIN" ]] || die "Codex Desktop binary not found at $CODEX_APP_BIN"
205
+
206
+ codex_home="$(codex_home_for_profile "$profile")"
207
+ ensure_home "$codex_home"
208
+ quit_codex_app
209
+
210
+ local log_dir="$codex_home/logs"
211
+ local log_file="$log_dir/desktop.log"
212
+ ensure_home "$log_dir"
213
+ (umask 077 && : > "$log_file")
214
+ chmod 600 "$log_file" 2> /dev/null || true
215
+
216
+ note "Launching Codex Desktop with CODEX_HOME=$codex_home"
217
+ note "Log: $log_file"
218
+ (umask 077 && nohup env CODEX_HOME="$codex_home" "$CODEX_APP_BIN" "$workspace" > "$log_file" 2>&1 &)
219
+ }
220
+
221
+ command_cli() {
222
+ local profile="${1:-}"
223
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM cli <profile> [codex-args...]"
224
+ shift || true
225
+
226
+ local codex_home codex_cli
227
+ codex_home="$(codex_home_for_profile "$profile")"
228
+ ensure_home "$codex_home"
229
+ codex_cli="$(find_codex_cli)"
230
+
231
+ exec env CODEX_HOME="$codex_home" "$codex_cli" "$@"
232
+ }
233
+
234
+ command_login() {
235
+ local profile="${1:-}"
236
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM login <profile> [codex-login-args...]"
237
+ shift || true
238
+
239
+ local codex_home codex_cli
240
+ codex_home="$(codex_home_for_profile "$profile")"
241
+ ensure_home "$codex_home"
242
+ codex_cli="$(find_codex_cli)"
243
+
244
+ exec env CODEX_HOME="$codex_home" "$codex_cli" login "$@"
245
+ }
246
+
247
+ command_init() {
248
+ local profile="${1:-}"
249
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM init <profile>"
250
+ shift || true
251
+ [[ "$#" -eq 0 ]] || die "Usage: $PROGRAM init <profile>"
252
+
253
+ local codex_home existed
254
+ codex_home="$(codex_home_for_profile "$profile")"
255
+ existed=no
256
+ [[ -d "$codex_home" ]] && existed=yes
257
+
258
+ ensure_home "$codex_home"
259
+
260
+ if [[ "$existed" == "yes" ]]; then
261
+ note "Already initialized $profile ($codex_home)"
262
+ else
263
+ note "Initialized $profile ($codex_home)"
264
+ fi
265
+ }
266
+
267
+ command_remove() {
268
+ local profile="${1:-}"
269
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM remove <profile> [--yes]"
270
+ shift || true
271
+
272
+ local yes=no
273
+ while [[ "$#" -gt 0 ]]; do
274
+ case "$1" in
275
+ --yes | -y)
276
+ yes=yes
277
+ ;;
278
+ *)
279
+ die "Usage: $PROGRAM remove <profile> [--yes]"
280
+ ;;
281
+ esac
282
+ shift
283
+ done
284
+
285
+ local codex_home confirmation
286
+ codex_home="$(codex_home_for_profile "$profile")"
287
+
288
+ if [[ ! -d "$codex_home" ]]; then
289
+ note "Not initialized $profile ($codex_home)"
290
+ return 0
291
+ fi
292
+
293
+ if [[ "$yes" != "yes" ]]; then
294
+ printf "Type '%s' to permanently remove %s: " "$profile" "$codex_home" >&2
295
+ if ! IFS= read -r confirmation && [[ -z "$confirmation" ]]; then
296
+ die "Confirmation required."
297
+ fi
298
+ if [[ "$confirmation" != "$profile" ]]; then
299
+ die "Confirmation did not match; nothing removed."
300
+ fi
301
+ fi
302
+
303
+ rm -rf -- "$codex_home" || die "Cannot remove profile home: $codex_home"
304
+ note "Removed $profile ($codex_home)"
305
+ }
306
+
307
+ command_status_one() {
308
+ local profile="$1"
309
+ local codex_home codex_cli
310
+ local status_output status_code
311
+
312
+ codex_home="$(codex_home_for_profile "$profile")"
313
+
314
+ if [[ ! -d "$codex_home" ]]; then
315
+ printf '%s (%s): Not initialized\n' "$profile" "$codex_home"
316
+ return 0
317
+ fi
318
+
319
+ codex_cli="$(find_codex_cli)"
320
+
321
+ printf '%s (%s): ' "$profile" "$codex_home"
322
+
323
+ set +e
324
+ status_output="$(env CODEX_HOME="$codex_home" "$codex_cli" login status 2>&1)"
325
+ status_code=$?
326
+ set -e
327
+
328
+ printf '%s\n' "$status_output"
329
+
330
+ if [[ "$status_code" -eq 0 || "$status_output" == "Not logged in" ]]; then
331
+ return 0
332
+ fi
333
+
334
+ return "$status_code"
335
+ }
336
+
337
+ json_escape() {
338
+ local value="$1"
339
+
340
+ value=${value//\\/\\\\}
341
+ value=${value//\"/\\\"}
342
+ value=${value//$'\b'/\\b}
343
+ value=${value//$'\f'/\\f}
344
+ value=${value//$'\n'/\\n}
345
+ value=${value//$'\r'/\\r}
346
+ value=${value//$'\t'/\\t}
347
+ value=${value//$'\001'/\\u0001}
348
+ value=${value//$'\002'/\\u0002}
349
+ value=${value//$'\003'/\\u0003}
350
+ value=${value//$'\004'/\\u0004}
351
+ value=${value//$'\005'/\\u0005}
352
+ value=${value//$'\006'/\\u0006}
353
+ value=${value//$'\007'/\\u0007}
354
+ value=${value//$'\013'/\\u000b}
355
+ value=${value//$'\016'/\\u000e}
356
+ value=${value//$'\017'/\\u000f}
357
+ value=${value//$'\020'/\\u0010}
358
+ value=${value//$'\021'/\\u0011}
359
+ value=${value//$'\022'/\\u0012}
360
+ value=${value//$'\023'/\\u0013}
361
+ value=${value//$'\024'/\\u0014}
362
+ value=${value//$'\025'/\\u0015}
363
+ value=${value//$'\026'/\\u0016}
364
+ value=${value//$'\027'/\\u0017}
365
+ value=${value//$'\030'/\\u0018}
366
+ value=${value//$'\031'/\\u0019}
367
+ value=${value//$'\032'/\\u001a}
368
+ value=${value//$'\033'/\\u001b}
369
+ value=${value//$'\034'/\\u001c}
370
+ value=${value//$'\035'/\\u001d}
371
+ value=${value//$'\036'/\\u001e}
372
+ value=${value//$'\037'/\\u001f}
373
+ printf '%s' "$value"
374
+ }
375
+
376
+ json_string() {
377
+ printf '"'
378
+ json_escape "$1"
379
+ printf '"'
380
+ }
381
+
382
+ json_status_object() {
383
+ local profile="$1"
384
+ local codex_home codex_cli status_output status_code state
385
+
386
+ codex_home="$(codex_home_for_profile "$profile")"
387
+
388
+ if [[ ! -d "$codex_home" ]]; then
389
+ printf '{"name":'
390
+ json_string "$profile"
391
+ printf ',"home":'
392
+ json_string "$codex_home"
393
+ printf ',"state":"not_initialized","status":"Not initialized","exit_code":0}'
394
+ return 0
395
+ fi
396
+
397
+ if ! codex_cli="$(try_find_codex_cli)"; then
398
+ printf '{"name":'
399
+ json_string "$profile"
400
+ printf ',"home":'
401
+ json_string "$codex_home"
402
+ printf ',"state":"error","status":'
403
+ json_string "$(codex_cli_error_message)"
404
+ printf ',"exit_code":1}'
405
+ return 1
406
+ fi
407
+
408
+ set +e
409
+ status_output="$(env CODEX_HOME="$codex_home" "$codex_cli" login status 2>&1)"
410
+ status_code=$?
411
+ set -e
412
+
413
+ if [[ "$status_code" -eq 0 ]]; then
414
+ state=ok
415
+ elif [[ "$status_output" == "Not logged in" ]]; then
416
+ state=not_logged_in
417
+ else
418
+ state=error
419
+ fi
420
+
421
+ printf '{"name":'
422
+ json_string "$profile"
423
+ printf ',"home":'
424
+ json_string "$codex_home"
425
+ printf ',"state":'
426
+ json_string "$state"
427
+ printf ',"status":'
428
+ json_string "$status_output"
429
+ printf ',"exit_code":%s}' "$status_code"
430
+
431
+ if [[ "$state" == "error" ]]; then
432
+ return "$status_code"
433
+ fi
434
+
435
+ return 0
436
+ }
437
+
438
+ json_emit_status_profiles_array() {
439
+ local requested_profile="${1:-}"
440
+ local status_code=0
441
+ local first=yes
442
+ local profile object object_status
443
+
444
+ printf '['
445
+
446
+ if [[ -n "$requested_profile" ]]; then
447
+ set +e
448
+ object="$(json_status_object "$requested_profile")"
449
+ object_status=$?
450
+ set -e
451
+ printf '%s' "$object"
452
+ printf ']'
453
+ return "$object_status"
454
+ fi
455
+
456
+ for profile in default $(discover_profiles no); do
457
+ if [[ "$first" == "yes" ]]; then
458
+ first=no
459
+ else
460
+ printf ','
461
+ fi
462
+
463
+ set +e
464
+ object="$(json_status_object "$profile")"
465
+ object_status=$?
466
+ set -e
467
+ printf '%s' "$object"
468
+
469
+ if [[ "$object_status" -ne 0 ]]; then
470
+ status_code="$object_status"
471
+ fi
472
+ done
473
+
474
+ printf ']'
475
+ return "$status_code"
476
+ }
477
+
478
+ command_status_json() {
479
+ local profile="${1:-}"
480
+ local status_code=0
481
+
482
+ printf '{"profiles":'
483
+ set +e
484
+ json_emit_status_profiles_array "$profile"
485
+ status_code=$?
486
+ set -e
487
+ printf '}\n'
488
+
489
+ return "$status_code"
490
+ }
491
+
492
+ command_status() {
493
+ local status_code=0
494
+ local json=no
495
+ local profile=""
496
+
497
+ while [[ "$#" -gt 0 ]]; do
498
+ case "$1" in
499
+ --json | -j)
500
+ json=yes
501
+ ;;
502
+ -*)
503
+ die "Unknown status option '$1'."
504
+ ;;
505
+ *)
506
+ [[ -z "$profile" ]] || die "Usage: $PROGRAM status [--json] [profile]"
507
+ profile="$1"
508
+ ;;
509
+ esac
510
+ shift
511
+ done
512
+
513
+ if [[ "$json" == "yes" ]]; then
514
+ command_status_json "$profile"
515
+ return $?
516
+ fi
517
+
518
+ if [[ -n "$profile" ]]; then
519
+ command_status_one "$profile"
520
+ return $?
521
+ fi
522
+
523
+ command_status_one default || status_code=$?
524
+
525
+ local profile
526
+ for profile in $(discover_profiles no); do
527
+ command_status_one "$profile" || status_code=$?
528
+ done
529
+
530
+ return "$status_code"
531
+ }
532
+
533
+ command_list() {
534
+ discover_profiles yes
535
+ }
536
+
537
+ command_path() {
538
+ local profile="${1:-}"
539
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM path <profile>"
540
+ codex_home_for_profile "$profile"
541
+ }
542
+
543
+ command_logs() {
544
+ local profile="${1:-}"
545
+ [[ -n "$profile" ]] || die "Usage: $PROGRAM logs <profile> [--path|--tail [lines]]"
546
+ shift || true
547
+
548
+ local mode="cat"
549
+ local lines=50
550
+
551
+ while [[ "$#" -gt 0 ]]; do
552
+ case "$1" in
553
+ --path)
554
+ mode="path"
555
+ ;;
556
+ --tail)
557
+ mode="tail"
558
+ if [[ -n "${2:-}" && "$2" != -* ]]; then
559
+ lines="$2"
560
+ shift
561
+ fi
562
+ ;;
563
+ -*)
564
+ die "Unknown logs option '$1'."
565
+ ;;
566
+ *)
567
+ die "Usage: $PROGRAM logs <profile> [--path|--tail [lines]]"
568
+ ;;
569
+ esac
570
+ shift
571
+ done
572
+
573
+ [[ "$lines" =~ ^[0-9]+$ ]] || die "Tail line count must be a non-negative integer."
574
+
575
+ local codex_home log_file
576
+ codex_home="$(codex_home_for_profile "$profile")"
577
+ log_file="$codex_home/logs/desktop.log"
578
+
579
+ if [[ "$mode" == "path" ]]; then
580
+ printf '%s\n' "$log_file"
581
+ return 0
582
+ fi
583
+
584
+ [[ -f "$log_file" ]] || die "No desktop log for $profile ($log_file)."
585
+
586
+ if [[ "$mode" == "tail" ]]; then
587
+ tail -n "$lines" "$log_file"
588
+ else
589
+ cat "$log_file"
590
+ fi
591
+ }
592
+
593
+ config_file_looks_sensitive() {
594
+ local file="$1"
595
+
596
+ grep -Eiq '(auth|token|secret|password|passwd|credential|api[_-]?key|access[_-]?key|private[_-]?key|oauth|bearer)' "$file"
597
+ }
598
+
599
+ command_clone_config() {
600
+ local source_profile="" target_profile="" force=no
601
+
602
+ while [[ "$#" -gt 0 ]]; do
603
+ case "$1" in
604
+ --force | -f)
605
+ force=yes
606
+ ;;
607
+ -*)
608
+ die "Usage: $PROGRAM clone-config <source-profile> <target-profile> [--force]"
609
+ ;;
610
+ *)
611
+ if [[ -z "$source_profile" ]]; then
612
+ source_profile="$1"
613
+ elif [[ -z "$target_profile" ]]; then
614
+ target_profile="$1"
615
+ else
616
+ die "Usage: $PROGRAM clone-config <source-profile> <target-profile> [--force]"
617
+ fi
618
+ ;;
619
+ esac
620
+ shift
621
+ done
622
+
623
+ [[ -n "$source_profile" && -n "$target_profile" ]] || die "Usage: $PROGRAM clone-config <source-profile> <target-profile> [--force]"
624
+ [[ "$source_profile" != "$target_profile" ]] || die "Source and target profiles must be different."
625
+
626
+ local source_home target_home
627
+ source_home="$(codex_home_for_profile "$source_profile")"
628
+ target_home="$(codex_home_for_profile "$target_profile")"
629
+
630
+ [[ -d "$source_home" ]] || die "Source profile is not initialized: $source_profile ($source_home)"
631
+ ensure_home "$target_home"
632
+
633
+ local safe_files=("config.toml" "AGENTS.md" "instructions.md" "custom-instructions.md")
634
+ local file source_file target_file copied failed
635
+ copied=0
636
+ failed=0
637
+
638
+ for file in "${safe_files[@]}"; do
639
+ source_file="$source_home/$file"
640
+ target_file="$target_home/$file"
641
+ [[ -f "$source_file" ]] || continue
642
+
643
+ if [[ -L "$source_file" ]]; then
644
+ note "Refusing to copy $file because it is a symlink."
645
+ failed=1
646
+ continue
647
+ fi
648
+
649
+ if config_file_looks_sensitive "$source_file"; then
650
+ note "Refusing to copy $file because it contains sensitive-looking keys."
651
+ failed=1
652
+ continue
653
+ fi
654
+
655
+ if [[ -L "$target_file" ]]; then
656
+ note "Refusing to overwrite $file because the target is a symlink."
657
+ failed=1
658
+ continue
659
+ fi
660
+
661
+ if [[ -e "$target_file" && "$force" != "yes" ]]; then
662
+ note "Refusing to overwrite $file in $target_home. Use --force to overwrite."
663
+ failed=1
664
+ continue
665
+ fi
666
+
667
+ install -m 600 "$source_file" "$target_file" || die "Cannot copy $file to $target_home"
668
+ note "Copied $file"
669
+ copied=$((copied + 1))
670
+ done
671
+
672
+ if [[ "$failed" -ne 0 ]]; then
673
+ return 1
674
+ fi
675
+
676
+ if [[ "$copied" -eq 0 ]]; then
677
+ note "No safe config files found to clone from $source_profile."
678
+ fi
679
+ }
680
+
681
+ command_version() {
682
+ printf 'codex-profile %s\n' "$VERSION"
683
+ }
684
+
685
+ upgrade_cache_dir() {
686
+ if [[ -n "${CODEX_PROFILE_UPGRADE_CACHE:-}" ]]; then
687
+ printf '%s\n' "$CODEX_PROFILE_UPGRADE_CACHE"
688
+ elif [[ -n "${XDG_CACHE_HOME:-}" ]]; then
689
+ printf '%s/codex-profile/source\n' "$XDG_CACHE_HOME"
690
+ else
691
+ printf '%s/.cache/codex-profile/source\n' "$HOME"
692
+ fi
693
+ }
694
+
695
+ upgrade_prefix() {
696
+ printf '%s\n' "${CODEX_PROFILE_UPGRADE_PREFIX:-$HOME/.local}"
697
+ }
698
+
699
+ upgrade_print_plan() {
700
+ local repo="$1"
701
+ local ref="$2"
702
+ local cache="$3"
703
+ local prefix="$4"
704
+
705
+ note "Upgrade plan"
706
+ note "Repository: $repo"
707
+ note "Ref: $ref"
708
+ note "Cache: $cache"
709
+ note "Install prefix: $prefix"
710
+ }
711
+
712
+ version_core() {
713
+ local value="$1"
714
+
715
+ value="${value#codex-profile }"
716
+ value="${value%%$'\n'*}"
717
+ value="${value%%-*}"
718
+ printf '%s\n' "$value"
719
+ }
720
+
721
+ version_is_older_than_current() {
722
+ local candidate="$1"
723
+ local current="$VERSION"
724
+ local candidate_core current_core
725
+ local candidate_parts current_parts candidate_part current_part index
726
+
727
+ candidate_core="$(version_core "$candidate")"
728
+ current_core="$(version_core "$current")"
729
+
730
+ IFS=. read -r -a candidate_parts <<< "$candidate_core"
731
+ IFS=. read -r -a current_parts <<< "$current_core"
732
+
733
+ for index in 0 1 2; do
734
+ candidate_part="${candidate_parts[$index]:-0}"
735
+ current_part="${current_parts[$index]:-0}"
736
+
737
+ [[ "$candidate_part" =~ ^[0-9]+$ ]] || return 1
738
+ [[ "$current_part" =~ ^[0-9]+$ ]] || return 1
739
+
740
+ if ((10#$candidate_part < 10#$current_part)); then
741
+ return 0
742
+ fi
743
+ if ((10#$candidate_part > 10#$current_part)); then
744
+ return 1
745
+ fi
746
+ done
747
+
748
+ return 1
749
+ }
750
+
751
+ script_declared_version_output() {
752
+ local script="$1"
753
+ local declared_version
754
+
755
+ declared_version="$(sed -n 's/^VERSION="\([^"]*\)".*/\1/p' "$script" | sed -n '1p')"
756
+ [[ -n "$declared_version" ]] || return 1
757
+ printf 'codex-profile %s\n' "$declared_version"
758
+ }
759
+
760
+ upgrade_checkout_ref() {
761
+ local cache="$1"
762
+ local ref="$2"
763
+
764
+ git -C "$cache" fetch --tags --prune origin
765
+ if git -C "$cache" show-ref --verify --quiet "refs/remotes/origin/$ref"; then
766
+ git -C "$cache" checkout --quiet -B "$ref" "origin/$ref"
767
+ else
768
+ git -C "$cache" checkout --quiet "$ref"
769
+ fi
770
+ }
771
+
772
+ command_upgrade() {
773
+ local dry_run=no
774
+ local prefix
775
+ local ref="$CODEX_PROFILE_UPGRADE_REF"
776
+ local repo="$CODEX_PROFILE_UPGRADE_REPO"
777
+ local cache
778
+ prefix="$(upgrade_prefix)"
779
+ cache="$(upgrade_cache_dir)"
780
+
781
+ while [[ "$#" -gt 0 ]]; do
782
+ case "$1" in
783
+ --dry-run | -n)
784
+ dry_run=yes
785
+ ;;
786
+ --prefix)
787
+ [[ -n "${2:-}" ]] || die "Usage: $PROGRAM upgrade [--dry-run] [--prefix <path>] [--ref <git-ref>]"
788
+ prefix="$2"
789
+ shift
790
+ ;;
791
+ --ref)
792
+ [[ -n "${2:-}" ]] || die "Usage: $PROGRAM upgrade [--dry-run] [--prefix <path>] [--ref <git-ref>]"
793
+ ref="$2"
794
+ shift
795
+ ;;
796
+ *)
797
+ die "Usage: $PROGRAM upgrade [--dry-run] [--prefix <path>] [--ref <git-ref>]"
798
+ ;;
799
+ esac
800
+ shift
801
+ done
802
+
803
+ [[ -n "$repo" ]] || die "Upgrade repository cannot be empty."
804
+ [[ -n "$ref" ]] || die "Upgrade ref cannot be empty."
805
+ [[ -n "$cache" ]] || die "Upgrade cache cannot be empty."
806
+ [[ -n "$prefix" ]] || die "Upgrade prefix cannot be empty."
807
+ [[ "$repo" != -* ]] || die "Upgrade repository must not start with '-'."
808
+ [[ "$ref" != -* ]] || die "Upgrade ref must not start with '-'."
809
+
810
+ upgrade_print_plan "$repo" "$ref" "$cache" "$prefix"
811
+
812
+ if [[ "$dry_run" == "yes" ]]; then
813
+ return 0
814
+ fi
815
+
816
+ command -v git > /dev/null 2>&1 || die "git is required to upgrade codex-profile."
817
+ command -v make > /dev/null 2>&1 || die "make is required to upgrade codex-profile."
818
+
819
+ if [[ -e "$cache" && ! -d "$cache/.git" ]]; then
820
+ die "Upgrade cache exists but is not a git checkout: $cache"
821
+ fi
822
+
823
+ if [[ ! -d "$cache/.git" ]]; then
824
+ mkdir -p "$(dirname "$cache")" || die "Cannot create upgrade cache parent: $(dirname "$cache")"
825
+ git clone "$repo" "$cache"
826
+ upgrade_checkout_ref "$cache" "$ref"
827
+ else
828
+ local origin dirty
829
+ origin="$(git -C "$cache" remote get-url origin 2> /dev/null || true)"
830
+ [[ "$origin" == "$repo" ]] || die "Cached upgrade checkout origin differs from $repo: $origin"
831
+
832
+ dirty="$(git -C "$cache" status --porcelain)"
833
+ [[ -z "$dirty" ]] || die "Cached upgrade checkout has local changes: $cache"
834
+
835
+ upgrade_checkout_ref "$cache" "$ref"
836
+ fi
837
+
838
+ [[ -f "$cache/Makefile" ]] || die "Upgrade checkout is missing Makefile: $cache"
839
+ [[ -f "$cache/bin/codex-profile" ]] || die "Upgrade checkout is missing bin/codex-profile: $cache"
840
+
841
+ local candidate_version
842
+ candidate_version="$(script_declared_version_output "$cache/bin/codex-profile" || true)"
843
+ [[ -n "$candidate_version" ]] || die "Refusing to install candidate without a declared VERSION."
844
+ if [[ -n "$candidate_version" ]] && version_is_older_than_current "$candidate_version"; then
845
+ die "Refusing to install older $candidate_version over codex-profile $VERSION."
846
+ fi
847
+
848
+ make -C "$cache" install PREFIX="$prefix"
849
+
850
+ local installed version_output
851
+ installed="$prefix/bin/codex-profile"
852
+ [[ -x "$installed" ]] || die "Upgrade did not install executable: $installed"
853
+ version_output="$("$installed" version 2> /dev/null || "$installed" --version 2> /dev/null || true)"
854
+ [[ -n "$version_output" ]] || version_output="codex-profile version unknown"
855
+ note "Installed $version_output to $installed"
856
+ }
857
+
858
+ command_completions() {
859
+ local shell="${1:-}"
860
+ [[ -n "$shell" ]] || die "Usage: $PROGRAM completions <bash|zsh|fish>"
861
+ shift || true
862
+ [[ "$#" -eq 0 ]] || die "Usage: $PROGRAM completions <bash|zsh|fish>"
863
+
864
+ case "$shell" in
865
+ bash)
866
+ cat <<'EOF'
867
+ _codex_profile()
868
+ {
869
+ local cur command profiles
870
+ COMPREPLY=()
871
+ cur="${COMP_WORDS[COMP_CWORD]}"
872
+ command="${COMP_WORDS[1]}"
873
+
874
+ if [[ "$COMP_CWORD" -eq 1 ]]; then
875
+ COMPREPLY=( $(compgen -W "app cli login init remove status path logs clone-config list doctor completions upgrade version help" -- "$cur") )
876
+ return 0
877
+ fi
878
+
879
+ case "$command" in
880
+ app|cli|login|init|remove|status|path|logs)
881
+ if [[ "$COMP_CWORD" -eq 2 ]]; then
882
+ profiles="$(codex-profile list 2>/dev/null)"
883
+ COMPREPLY=( $(compgen -W "default personal work $profiles" -- "$cur") )
884
+ fi
885
+ ;;
886
+ clone-config)
887
+ if [[ "$COMP_CWORD" -eq 2 || "$COMP_CWORD" -eq 3 ]]; then
888
+ profiles="$(codex-profile list 2>/dev/null)"
889
+ COMPREPLY=( $(compgen -W "default personal work $profiles" -- "$cur") )
890
+ fi
891
+ ;;
892
+ completions)
893
+ if [[ "$COMP_CWORD" -eq 2 ]]; then
894
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
895
+ fi
896
+ ;;
897
+ esac
898
+ }
899
+ complete -F _codex_profile codex-profile
900
+ EOF
901
+ ;;
902
+ zsh)
903
+ cat <<'EOF'
904
+ #compdef codex-profile
905
+
906
+ _codex_profile() {
907
+ local -a commands profiles shells
908
+ commands=(
909
+ app cli login init remove status path logs clone-config list doctor completions upgrade version help
910
+ )
911
+ profiles=(${(f)"$(codex-profile list 2>/dev/null)"} default personal work)
912
+ shells=(bash zsh fish)
913
+
914
+ if (( CURRENT == 2 )); then
915
+ _describe 'command' commands
916
+ return
917
+ fi
918
+
919
+ case "$words[2]" in
920
+ app|cli|login|init|remove|status|path|logs)
921
+ if (( CURRENT == 3 )); then
922
+ _describe 'profile' profiles
923
+ fi
924
+ ;;
925
+ clone-config)
926
+ if (( CURRENT == 3 || CURRENT == 4 )); then
927
+ _describe 'profile' profiles
928
+ fi
929
+ ;;
930
+ completions)
931
+ if (( CURRENT == 3 )); then
932
+ _describe 'shell' shells
933
+ fi
934
+ ;;
935
+ esac
936
+ }
937
+
938
+ _codex_profile "$@"
939
+ EOF
940
+ ;;
941
+ fish)
942
+ cat <<'EOF'
943
+ complete -c codex-profile -f
944
+ complete -c codex-profile -n '__fish_is_first_arg' -a 'app cli login init remove status path logs clone-config list doctor completions upgrade version help'
945
+ complete -c codex-profile -n '__fish_seen_subcommand_from app cli login init remove status path logs' -a '(codex-profile list 2>/dev/null) default personal work'
946
+ complete -c codex-profile -n '__fish_seen_subcommand_from clone-config' -a '(codex-profile list 2>/dev/null) default personal work'
947
+ complete -c codex-profile -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'
948
+ EOF
949
+ ;;
950
+ *)
951
+ die "Unsupported shell '$shell'. Use bash, zsh, or fish."
952
+ ;;
953
+ esac
954
+ }
955
+
956
+ command_doctor() {
957
+ local json=no
958
+ local codex_cli
959
+
960
+ while [[ "$#" -gt 0 ]]; do
961
+ case "$1" in
962
+ --json | -j)
963
+ json=yes
964
+ ;;
965
+ *)
966
+ die "Usage: $PROGRAM doctor [--json]"
967
+ ;;
968
+ esac
969
+ shift
970
+ done
971
+
972
+ if [[ "$json" == "yes" ]]; then
973
+ local desktop_found=false cli_version status_code=0
974
+ [[ -x "$CODEX_APP_BIN" ]] && desktop_found=true
975
+
976
+ printf '{"desktop":{"found":%s,"path":' "$desktop_found"
977
+ json_string "$CODEX_APP_BIN"
978
+ printf '}'
979
+
980
+ if codex_cli="$(try_find_codex_cli)"; then
981
+ cli_version="$("$codex_cli" --version 2>&1 || true)"
982
+ printf ',"cli":{"found":true,"path":'
983
+ json_string "$codex_cli"
984
+ printf ',"version":'
985
+ json_string "$cli_version"
986
+ printf '},"status":{"skipped":false,"profiles":'
987
+ set +e
988
+ json_emit_status_profiles_array
989
+ status_code=$?
990
+ set -e
991
+ printf '}}\n'
992
+ return "$status_code"
993
+ fi
994
+
995
+ printf ',"cli":{"found":false,"path":null,"version":null},"status":{"skipped":true,"reason":'
996
+ json_string "$(codex_cli_error_message)"
997
+ printf '}}\n'
998
+ return 0
999
+ fi
1000
+
1001
+ note "Codex profile doctor"
1002
+ note ""
1003
+
1004
+ if [[ -x "$CODEX_APP_BIN" ]]; then
1005
+ note "Desktop: $CODEX_APP_BIN"
1006
+ else
1007
+ note "Desktop: missing ($CODEX_APP_BIN)"
1008
+ fi
1009
+
1010
+ if codex_cli="$(find_codex_cli 2> /dev/null)"; then
1011
+ note "CLI: $codex_cli"
1012
+ "$codex_cli" --version || true
1013
+ else
1014
+ note "CLI: missing"
1015
+ note ""
1016
+ note "Status: skipped"
1017
+ return 0
1018
+ fi
1019
+
1020
+ note ""
1021
+ command_status
1022
+ }
1023
+
1024
+ main() {
1025
+ local command="${1:-help}"
1026
+ shift || true
1027
+
1028
+ case "$command" in
1029
+ app)
1030
+ command_app "$@"
1031
+ ;;
1032
+ cli)
1033
+ command_cli "$@"
1034
+ ;;
1035
+ login)
1036
+ command_login "$@"
1037
+ ;;
1038
+ init)
1039
+ command_init "$@"
1040
+ ;;
1041
+ remove)
1042
+ command_remove "$@"
1043
+ ;;
1044
+ status)
1045
+ command_status "$@"
1046
+ ;;
1047
+ path)
1048
+ command_path "$@"
1049
+ ;;
1050
+ logs)
1051
+ command_logs "$@"
1052
+ ;;
1053
+ clone-config)
1054
+ command_clone_config "$@"
1055
+ ;;
1056
+ list)
1057
+ command_list
1058
+ ;;
1059
+ doctor)
1060
+ command_doctor "$@"
1061
+ ;;
1062
+ completions)
1063
+ command_completions "$@"
1064
+ ;;
1065
+ upgrade)
1066
+ command_upgrade "$@"
1067
+ ;;
1068
+ version | --version)
1069
+ command_version
1070
+ ;;
1071
+ help | -h | --help)
1072
+ usage
1073
+ ;;
1074
+ *)
1075
+ die "Unknown command '$command'. See '$PROGRAM help'."
1076
+ ;;
1077
+ esac
1078
+ }
1079
+
1080
+ main "$@"