Repository navigation
Expand file tree
/
Copy pathcodex_switcher.py
More file actions
3900 lines (3580 loc) · 197 KB
/
Copy pathcodex_switcher.py
File metadata and controls
3900 lines (3580 loc) · 197 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import base64
import ctypes
from ctypes import wintypes
import datetime as dt
import getpass
import hashlib
import json
import locale
import ntpath
import os
from pathlib import Path
import re
import shutil
import sqlite3
import subprocess
import sys
import threading
import time
import tomllib
import uuid
from dataclasses import dataclass
from typing import Callable, Iterable
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from contextlib import closing
import tkinter as tk
from tkinter import filedialog, ttk
APP_NAME = "CodexShift"
APP_VERSION = "1.8.1"
OFFICIAL_PROVIDER = "openai"
BUILTIN_OFFICIAL_ID = "codexshift-native-official"
PROCESS_NAMES = ("ChatGPT.exe", "codex.exe", "codex-code-mode-host.exe")
MAC_PROCESS_NAMES = ("Codex", "ChatGPT", "codex", "codex-code-mode-host")
SECRET_KEYS = {"OPENAI_API_KEY", "access_token", "refresh_token", "id_token"}
IS_WINDOWS = os.name == "nt"
IS_MACOS = sys.platform == "darwin"
UI_FONT = "PingFang SC" if IS_MACOS else "Microsoft YaHei UI"
MONO_FONT = "Menlo" if IS_MACOS else "Cascadia Mono"
LANGUAGE_LABELS = {
"zh-CN": "简体中文",
"en": "English",
"ja": "日本語",
"ko": "한국어",
}
CURRENT_LANGUAGE = "zh-CN"
TRANSLATIONS = {
"en": {
"subtitle": "Provider switching & history sync", "language_label": "Language",
"current_connection": "Current connection", "refresh": "↻ Refresh", "path_settings": "Path",
"provider_profiles": "Provider profiles", "profile_count": "{count} profiles",
"new_profile": "+ New", "edit": "Edit", "delete": "Delete",
"type": "Type", "model": "Model", "source": "Source", "status": "Status",
"official_account": "Official account", "third_party_api": "Third-party API", "current": "● Active",
"auto": "Auto", "unknown": "Unknown", "loading": "Loading…", "detecting": "Detecting…",
"verify_on_switch": "Verify third-party API", "auto_close": "Close Codex automatically",
"restart_after": "Reopen after switching", "switch_selected": "Switch to selected Provider →",
"test_api": "✓ Test API", "repair_history": "Repair history index", "open_backups": "Open backups",
"execution_log": "Execution log", "select_profile": "Select a Provider first.",
"builtin_official": "OpenAI official account (native)",
"builtin_login_needed": "OpenAI official account (sign-in required)",
"source_saved_login": "Built-in · Login saved", "source_login_needed": "Built-in · Sign-in required",
"source_local": "Local encrypted profile", "read_profiles": "Loaded {count} local Providers.",
"validating": "Validating the target API and credentials…", "closing_codex": "Closing Codex to avoid locked databases…",
"writing_credentials": "Writing configuration and credentials atomically…",
"syncing_history": "Synchronizing history Provider values and rebuilding indexes…",
"switch_complete": "Switch complete: scanned {rollouts}, rewrote {changed}, updated {updated}, inserted {inserted}.",
"official_login_ready": "Native OpenAI mode restored. Sign in to ChatGPT after opening Codex.",
"rolling_back": "An error occurred. Rolling back automatically…", "restarting_codex": "Reopening Codex…",
"scan_progress": "Scanned {index}/{total} history files…", "codex_closed": "Codex processes closed safely.",
"imported_profile": "Encrypted and imported the current API profile “{name}” into CodexShift.",
"skip_snapshot": "Skipped current profile snapshot: {error}", "operation_failed": "Failed: {error}",
"testing_profile": "Testing API for “{name}”…", "api_passed": "API test passed: {result}",
"repair_complete": "Repair complete: {stats}", "profile_deleted": "Deleted local profile “{name}”.",
"home_changed": "Codex Home changed to: {path}", "home_auto": "Codex Home auto-detection restored: {path}",
"choose_home": "Choose Codex Home…", "reset_home": "Restore auto-detection",
"new_dialog": "New third-party API profile", "edit_dialog": "Edit third-party API profile",
"profile_name": "Profile name", "model_name": "Model", "fetch_models": "Fetch models",
"cancel": "Cancel", "save_profile": "Save profile", "save_changes": "Save changes",
"new_hint": "You can test the API before saving. The active Codex configuration will not change.",
"edit_hint": "Leave API Key blank to keep the current key. Switch again to apply changes.",
"saved_profile": "{action} third-party profile “{name}”; credentials are encrypted.",
"created": "Created", "updated_action": "Updated",
},
"ja": {
"subtitle": "プロバイダー切替と履歴同期", "language_label": "言語", "current_connection": "現在の接続", "refresh": "↻ 更新", "path_settings": "パス設定",
"provider_profiles": "プロバイダー設定", "profile_count": "{count} 件", "new_profile": "+ 新規", "edit": "編集", "delete": "削除",
"type": "種類", "model": "モデル", "source": "保存元", "status": "状態", "official_account": "公式アカウント",
"third_party_api": "サードパーティ API", "current": "● 使用中", "auto": "自動", "unknown": "未認識",
"loading": "読み込み中…", "detecting": "確認中…", "verify_on_switch": "切替時に API を確認",
"auto_close": "Codex を自動終了", "restart_after": "完了後に再起動", "switch_selected": "選択した Provider に切替 →",
"test_api": "✓ API 確認", "repair_history": "履歴インデックス修復", "open_backups": "バックアップを開く",
"execution_log": "実行ログ", "select_profile": "Provider を選択してください。", "builtin_official": "OpenAI 公式アカウント(ネイティブ)",
"builtin_login_needed": "OpenAI 公式アカウント(再ログイン必要)", "source_saved_login": "内蔵 · ログイン保存済み",
"source_login_needed": "内蔵 · ログイン必要", "source_local": "ローカル暗号化設定", "read_profiles": "ローカル Provider を {count} 件読み込みました。",
"validating": "接続先と認証情報を確認中…", "closing_codex": "データベース保護のため Codex を終了中…",
"writing_credentials": "設定と認証情報を安全に書き込み中…", "syncing_history": "履歴とインデックスを同期中…",
"switch_complete": "切替完了:履歴 {rollouts} 件、書換 {changed} 件、更新 {updated} 件、追加 {inserted} 件。",
"official_login_ready": "公式モードに戻しました。Codex を開いて ChatGPT にログインしてください。",
"rolling_back": "エラーが発生したため自動復元しています…", "restarting_codex": "Codex を再起動中…",
"scan_progress": "履歴ファイル {index}/{total} 件を確認済み…", "codex_closed": "Codex を安全に終了しました。",
"imported_profile": "現在の API 設定「{name}」を暗号化して取り込みました。", "skip_snapshot": "現在の設定保存をスキップ:{error}",
"operation_failed": "失敗:{error}", "testing_profile": "「{name}」の API を確認中…", "api_passed": "API 確認成功:{result}",
"repair_complete": "修復完了:{stats}", "profile_deleted": "ローカル設定「{name}」を削除しました。",
"home_changed": "Codex Home を変更:{path}", "home_auto": "Codex Home の自動認識に戻しました:{path}",
"choose_home": "Codex Home を選択…", "reset_home": "自動認識に戻す", "new_dialog": "サードパーティ API 設定を追加",
"edit_dialog": "サードパーティ API 設定を編集", "profile_name": "設定名", "model_name": "モデル名", "fetch_models": "モデル取得",
"cancel": "キャンセル", "save_profile": "保存", "save_changes": "変更を保存", "new_hint": "保存前に API を確認できます。現在の設定は変更されません。",
"edit_hint": "API Key を空欄にすると現在のキーを保持します。", "saved_profile": "サードパーティ設定「{name}」を{action}しました。認証情報は暗号化されています。",
"created": "追加", "updated_action": "更新",
},
"ko": {
"subtitle": "Provider 전환 및 기록 동기화", "language_label": "언어", "current_connection": "현재 연결", "refresh": "↻ 새로고침", "path_settings": "경로 설정",
"provider_profiles": "Provider 설정", "profile_count": "{count}개 설정", "new_profile": "+ 새 설정", "edit": "수정", "delete": "삭제",
"type": "유형", "model": "모델", "source": "저장 위치", "status": "상태", "official_account": "공식 계정",
"third_party_api": "서드파티 API", "current": "● 사용 중", "auto": "자동", "unknown": "알 수 없음",
"loading": "불러오는 중…", "detecting": "확인 중…", "verify_on_switch": "전환 시 API 확인", "auto_close": "Codex 자동 종료",
"restart_after": "완료 후 다시 열기", "switch_selected": "선택한 Provider로 전환 →", "test_api": "✓ API 확인",
"repair_history": "기록 인덱스 복구", "open_backups": "백업 열기", "execution_log": "실행 로그", "select_profile": "Provider를 먼저 선택하세요.",
"builtin_official": "OpenAI 공식 계정(기본)", "builtin_login_needed": "OpenAI 공식 계정(로그인 필요)",
"source_saved_login": "기본 · 로그인 저장됨", "source_login_needed": "기본 · 로그인 필요", "source_local": "로컬 암호화 설정",
"read_profiles": "로컬 Provider {count}개를 불러왔습니다.", "validating": "대상 API와 인증 정보를 확인하는 중…",
"closing_codex": "데이터베이스 보호를 위해 Codex를 종료하는 중…", "writing_credentials": "설정과 인증 정보를 안전하게 쓰는 중…",
"syncing_history": "기록 Provider와 인덱스를 동기화하는 중…", "switch_complete": "전환 완료: 기록 {rollouts}, 변경 {changed}, 업데이트 {updated}, 추가 {inserted}.",
"official_login_ready": "공식 모드로 복원했습니다. Codex에서 ChatGPT에 로그인하세요.", "rolling_back": "오류가 발생해 자동 복원 중…",
"restarting_codex": "Codex를 다시 여는 중…", "scan_progress": "기록 파일 {index}/{total}개 확인…", "codex_closed": "Codex를 안전하게 종료했습니다.",
"imported_profile": "현재 API 설정 “{name}”을 암호화해 가져왔습니다.", "skip_snapshot": "현재 설정 저장 건너뜀: {error}",
"operation_failed": "실패: {error}", "testing_profile": "“{name}” API 확인 중…", "api_passed": "API 확인 성공: {result}",
"repair_complete": "복구 완료: {stats}", "profile_deleted": "로컬 설정 “{name}”을 삭제했습니다.", "home_changed": "Codex Home 변경: {path}",
"home_auto": "Codex Home 자동 감지 복원: {path}", "choose_home": "Codex Home 선택…", "reset_home": "자동 감지 복원",
"new_dialog": "서드파티 API 설정 추가", "edit_dialog": "서드파티 API 설정 수정", "profile_name": "설정 이름", "model_name": "모델 이름",
"fetch_models": "모델 가져오기", "cancel": "취소", "save_profile": "저장", "save_changes": "변경 저장",
"new_hint": "저장 전에 API를 확인할 수 있으며 현재 Codex 설정은 바뀌지 않습니다.", "edit_hint": "API Key를 비워 두면 기존 키를 유지합니다.",
"saved_profile": "서드파티 설정 “{name}”을 {action}했습니다. 인증 정보는 암호화됩니다.", "created": "추가", "updated_action": "업데이트",
},
}
TRANSLATIONS["zh-CN"] = {
"ok": "确定", "confirm": "继续", "cancel": "取消", "language_label": "语言",
"confirm_close": "切换时需要关闭正在运行的 Codex,未发送的输入会丢失。\n\n目标:{name}\n历史会自动备份并统一索引。\n\n继续吗?",
"confirm_official_login": "没有找到已保存的 ChatGPT 官方登录会话。\n\n继续后会安全备份当前第三方配置,并让 Codex 进入官方登录流程。请在浏览器中选择“使用 ChatGPT 登录”,再使用你原来的 Google 账号。\n\n在完成登录前,CodexShift 不会把它标记为“已保存登录”。",
"builtin_edit_locked": "内置官方账号配置由 CodexShift 维护,不能修改。", "builtin_delete_locked": "内置官方账号配置不能删除。",
"confirm_delete": "确定删除本地配置「{name}」吗?\n\n此操作不会删除历史记录,当前配置不能直接删除。",
"confirm_repair": "将历史统一到「{name}」并自动备份,继续吗?",
"home_not_detected": "这个目录里暂时没有发现 Codex 配置或历史文件。\n\n仍然将它设为 Codex Home 吗?",
"test_passed_dialog": "检测通过\n\n{name}\n{result}",
"official_mode_ready": "原生官方模式已就绪,切换后请在 Codex 中登录 OpenAI 账号",
"official_credentials_ok": "官方账号凭据结构正常",
"models_found": "接口连通,自动发现 {count} 个模型",
"models_unavailable": "接口可达,但无法自动发现模型:{error}",
"fetching_models": "正在请求 /models 并解析模型列表…",
"models_ready": "已发现 {count} 个模型,请从下拉列表选择。",
"models_dialog": "已自动发现 {count} 个模型。",
"models_failed": "模型发现失败:{error};仍可手动填写。",
"testing_api_status": "正在检测接口和 API Key…",
"test_passed_status": "检测通过:{result}",
"test_failed_status": "检测失败:{error}",
}
TRANSLATIONS["en"].update({
"ok": "OK", "confirm": "Continue", "cancel": "Cancel",
"confirm_close": "Codex must be closed before switching. Unsent input will be lost.\n\nTarget: {name}\nHistory will be backed up and reindexed automatically.\n\nContinue?",
"confirm_official_login": "No saved ChatGPT sign-in session was found.\n\nCodexShift will back up the current API profile and enter the official sign-in flow. Choose “Sign in with ChatGPT” in your browser, then use your original Google account.\n\nThe login will not be marked as saved until authorization is complete.",
"builtin_edit_locked": "The built-in official account profile is managed by CodexShift and cannot be edited.",
"builtin_delete_locked": "The built-in official account profile cannot be deleted.",
"confirm_delete": "Delete local profile “{name}”?\n\nHistory will not be deleted. The active profile cannot be deleted directly.",
"confirm_repair": "Rewrite history to “{name}” and create a backup. Continue?",
"home_not_detected": "No Codex configuration or history was found in this folder.\n\nUse it as Codex Home anyway?",
"test_passed_dialog": "Test passed\n\n{name}\n{result}",
"official_mode_ready": "Native OpenAI mode is ready. Sign in after switching.",
"official_credentials_ok": "Official account credentials are valid",
"models_found": "API connected; discovered {count} models", "models_unavailable": "API reachable, but models could not be discovered: {error}",
"fetching_models": "Requesting /models and parsing the model list…", "models_ready": "Found {count} models. Select one from the list.",
"models_dialog": "Discovered {count} models.", "models_failed": "Model discovery failed: {error}. You can still enter one manually.",
"testing_api_status": "Testing the API and API Key…", "test_passed_status": "Test passed: {result}", "test_failed_status": "Test failed: {error}",
})
TRANSLATIONS["ja"].update({
"ok": "OK", "confirm": "続ける", "cancel": "キャンセル",
"confirm_close": "切替前に実行中の Codex を終了します。未送信の入力は失われます。\n\n切替先:{name}\n履歴は自動的にバックアップして再索引されます。\n\n続けますか?",
"confirm_official_login": "保存された ChatGPT ログインがありません。\n\n現在の API 設定をバックアップし、公式ログインを開始します。ブラウザーで「ChatGPT でログイン」を選び、以前の Google アカウントを使用してください。",
"builtin_edit_locked": "内蔵の公式アカウント設定は編集できません。", "builtin_delete_locked": "内蔵の公式アカウント設定は削除できません。",
"confirm_delete": "ローカル設定「{name}」を削除しますか?\n\n履歴は削除されません。使用中の設定は直接削除できません。",
"confirm_repair": "履歴を「{name}」に統一してバックアップします。続けますか?",
"home_not_detected": "このフォルダーに Codex の設定や履歴が見つかりません。\n\nCodex Home として使用しますか?",
"test_passed_dialog": "確認成功\n\n{name}\n{result}",
"official_mode_ready": "公式モードの準備ができました。切替後にログインしてください。", "official_credentials_ok": "公式アカウントの認証情報は正常です",
"models_found": "接続成功、{count} 個のモデルを取得", "models_unavailable": "接続済みですがモデルを取得できません:{error}",
"fetching_models": "/models からモデル一覧を取得中…", "models_ready": "{count} 個のモデルを取得しました。リストから選択してください。",
"models_dialog": "{count} 個のモデルを取得しました。", "models_failed": "モデル取得失敗:{error}。手動入力も可能です。",
"testing_api_status": "API と API Key を確認中…", "test_passed_status": "確認成功:{result}", "test_failed_status": "確認失敗:{error}",
})
TRANSLATIONS["ko"].update({
"ok": "확인", "confirm": "계속", "cancel": "취소",
"confirm_close": "전환 전에 실행 중인 Codex를 종료해야 합니다. 전송하지 않은 입력은 사라집니다.\n\n대상: {name}\n기록은 자동으로 백업하고 다시 색인합니다.\n\n계속할까요?",
"confirm_official_login": "저장된 ChatGPT 로그인 세션이 없습니다.\n\n현재 API 설정을 백업한 후 공식 로그인 절차를 시작합니다. 브라우저에서 ‘ChatGPT로 로그인’을 선택하고 기존 Google 계정을 사용하세요.",
"builtin_edit_locked": "기본 공식 계정 설정은 수정할 수 없습니다.", "builtin_delete_locked": "기본 공식 계정 설정은 삭제할 수 없습니다.",
"confirm_delete": "로컬 설정 “{name}”을 삭제할까요?\n\n기록은 삭제되지 않으며 현재 사용 중인 설정은 바로 삭제할 수 없습니다.",
"confirm_repair": "기록을 “{name}”으로 통일하고 백업할까요?",
"home_not_detected": "이 폴더에서 Codex 설정이나 기록을 찾지 못했습니다.\n\nCodex Home으로 사용할까요?",
"test_passed_dialog": "확인 성공\n\n{name}\n{result}",
"official_mode_ready": "공식 모드가 준비되었습니다. 전환 후 로그인하세요.", "official_credentials_ok": "공식 계정 인증 정보가 정상입니다",
"models_found": "연결 성공, 모델 {count}개 발견", "models_unavailable": "연결되었지만 모델을 찾지 못했습니다: {error}",
"fetching_models": "/models에서 모델 목록을 가져오는 중…", "models_ready": "모델 {count}개를 찾았습니다. 목록에서 선택하세요.",
"models_dialog": "모델 {count}개를 찾았습니다.", "models_failed": "모델 검색 실패: {error}. 직접 입력할 수도 있습니다.",
"testing_api_status": "API와 API Key 확인 중…", "test_passed_status": "확인 성공: {result}", "test_failed_status": "확인 실패: {error}",
})
_MIGRATION_TRANSLATIONS = {
"zh-CN": {"migration": "迁移项目与会话", "migration_title": "跨 Harness 迁移", "migration_source": "来源", "migration_codex": "Codex", "migration_dsh": "DeepSeek Harness", "migration_url": "DeepSeek Harness 地址", "migration_projects": "项目", "migration_sessions": "会话", "migration_recent": "最近", "migration_choose": "选择迁移内容", "migration_multi_hint": "直接点击即可多选;点击项目可选择其全部会话", "migration_mode": "迁移内容", "migration_handoff": "精简上下文(推荐)", "migration_handoff_desc": "按原消息结构导入近期上下文,不会触发模型回答。", "migration_full": "完整聊天记录", "migration_full_desc": "将用户与助手消息逐条还原为原生会话,不会触发模型或产生用量。", "migration_project_only": "只建立项目", "migration_project_only_desc": "仅建立项目入口,不导入原有会话和聊天内容。", "migration_refresh": "刷新", "migration_preview": "预览", "migration_import": "导入选中项", "migration_none": "没有找到项目或会话。", "migration_select": "请至少选择一个会话。", "migration_preview_text": "已选择 {projects} 个项目中的 {count} 个会话。", "migration_done": "已导入 {count} 项。", "migration_unavailable": "DeepSeek Harness 不可用:{error}", "migration_project_count": "{count} 个项目", "migration_session_count": "{count} 个会话", "migration_close_codex": "导入到 Codex 前请先关闭 Codex,避免历史数据库被占用。"},
"en": {"migration": "Migrate projects & sessions", "migration_title": "Cross-Harness migration", "migration_source": "Source", "migration_codex": "Codex", "migration_dsh": "DeepSeek Harness", "migration_url": "DeepSeek Harness URL", "migration_projects": "Projects", "migration_sessions": "Sessions", "migration_recent": "Recent", "migration_choose": "Choose content to migrate", "migration_multi_hint": "Click to select multiple items; click a project to select all its sessions", "migration_mode": "Content to migrate", "migration_handoff": "Compact context (recommended)", "migration_handoff_desc": "Import recent context as native messages without invoking a model.", "migration_full": "Complete chat history", "migration_full_desc": "Restore user and assistant messages individually without invoking a model or consuming tokens.", "migration_project_only": "Projects only", "migration_project_only_desc": "Create project entries without importing existing sessions or chat content.", "migration_refresh": "Refresh", "migration_preview": "Preview", "migration_import": "Import selected", "migration_none": "No projects or sessions found.", "migration_select": "Select at least one session.", "migration_preview_text": "Selected {count} session(s) from {projects} project(s).", "migration_done": "Imported {count} item(s).", "migration_unavailable": "DeepSeek Harness is unavailable: {error}", "migration_project_count": "{count} project(s)", "migration_session_count": "{count} session(s)", "migration_close_codex": "Close Codex before importing so its history database is not in use."},
"ja": {"migration": "プロジェクトと履歴を移行", "migration_title": "Harness 間の移行", "migration_source": "移行元", "migration_codex": "Codex", "migration_dsh": "DeepSeek Harness", "migration_url": "DeepSeek Harness URL", "migration_projects": "プロジェクト", "migration_sessions": "セッション", "migration_recent": "最近", "migration_choose": "移行する内容を選択", "migration_multi_hint": "クリックで複数選択。プロジェクトをクリックすると全セッションを選択", "migration_mode": "移行する内容", "migration_handoff": "簡潔なコンテキスト(推奨)", "migration_handoff_desc": "モデルを呼び出さず、最近の文脈をネイティブメッセージとして移行します。", "migration_full": "完全なチャット履歴", "migration_full_desc": "モデルやトークンを使わず、ユーザーとアシスタントの発言を個別に復元します。", "migration_project_only": "プロジェクトのみ", "migration_project_only_desc": "既存セッションや会話を取り込まず、プロジェクトだけを作成します。", "migration_refresh": "更新", "migration_preview": "プレビュー", "migration_import": "選択を移行", "migration_none": "プロジェクトまたはセッションがありません。", "migration_select": "セッションを1つ以上選択してください。", "migration_preview_text": "{projects} 件のプロジェクトから {count} 件のセッションを選択中。", "migration_done": "{count} 件を移行しました。", "migration_unavailable": "DeepSeek Harness を利用できません:{error}", "migration_project_count": "{count} 件のプロジェクト", "migration_session_count": "{count} 件のセッション", "migration_close_codex": "履歴データベースを保護するため、Codex を終了してから取り込んでください。"},
"ko": {"migration": "프로젝트 및 기록 이동", "migration_title": "Harness 간 이동", "migration_source": "원본", "migration_codex": "Codex", "migration_dsh": "DeepSeek Harness", "migration_url": "DeepSeek Harness URL", "migration_projects": "프로젝트", "migration_sessions": "세션", "migration_recent": "최근", "migration_choose": "이동할 항목 선택", "migration_multi_hint": "클릭하여 여러 항목 선택; 프로젝트를 클릭하면 모든 세션 선택", "migration_mode": "이동할 내용", "migration_handoff": "간결한 컨텍스트 (권장)", "migration_handoff_desc": "모델을 호출하지 않고 최근 문맥을 기본 메시지로 가져옵니다.", "migration_full": "전체 채팅 기록", "migration_full_desc": "모델이나 토큰을 사용하지 않고 사용자와 어시스턴트 메시지를 개별 복원합니다.", "migration_project_only": "프로젝트만", "migration_project_only_desc": "기존 세션이나 대화 없이 프로젝트 항목만 만듭니다.", "migration_refresh": "새로고침", "migration_preview": "미리보기", "migration_import": "선택 항목 이동", "migration_none": "프로젝트 또는 세션이 없습니다.", "migration_select": "세션을 하나 이상 선택하세요.", "migration_preview_text": "{projects}개 프로젝트에서 {count}개 세션을 선택했습니다.", "migration_done": "{count}개 항목을 이동했습니다.", "migration_unavailable": "DeepSeek Harness를 사용할 수 없습니다: {error}", "migration_project_count": "프로젝트 {count}개", "migration_session_count": "세션 {count}개", "migration_close_codex": "기록 데이터베이스 보호를 위해 Codex를 닫은 뒤 가져오세요."},
}
for _lang, _items in _MIGRATION_TRANSLATIONS.items():
TRANSLATIONS[_lang].update(_items)
_MIGRATION_PROGRESS_TRANSLATIONS = {
"zh-CN": {
"migration_progress_title": "导入进度",
"migration_log_title": "实时日志",
"migration_progress_ready": "选择内容后点击导入,这里会显示实时进度。",
"migration_progress_preparing": "正在准备导入 {count} 项…",
"migration_progress_connecting": "正在连接目标应用…",
"migration_progress_item": "正在导入 {current}/{total}:{title}",
"migration_progress_finalizing": "正在写入索引并完成收尾…",
"migration_progress_opening": "正在打开目标应用…",
"migration_progress_complete": "导入完成,共处理 {count} 项。",
"migration_progress_failed": "导入失败:{error}",
"migration_progress_busy": "导入正在进行,请等待完成。",
},
"en": {
"migration_progress_title": "Import progress",
"migration_log_title": "Live log",
"migration_progress_ready": "Choose items and start the import. Live progress will appear here.",
"migration_progress_preparing": "Preparing to import {count} item(s)…",
"migration_progress_connecting": "Connecting to the destination app…",
"migration_progress_item": "Importing {current}/{total}: {title}",
"migration_progress_finalizing": "Writing indexes and finalizing…",
"migration_progress_opening": "Opening the destination app…",
"migration_progress_complete": "Import complete. Processed {count} item(s).",
"migration_progress_failed": "Import failed: {error}",
"migration_progress_busy": "An import is running. Please wait for it to finish.",
},
"ja": {
"migration_progress_title": "移行の進捗",
"migration_log_title": "リアルタイムログ",
"migration_progress_ready": "項目を選んで移行を開始すると、ここに進捗が表示されます。",
"migration_progress_preparing": "{count} 件の移行を準備中…",
"migration_progress_connecting": "移行先アプリに接続中…",
"migration_progress_item": "移行中 {current}/{total}:{title}",
"migration_progress_finalizing": "インデックスを書き込み、仕上げ処理中…",
"migration_progress_opening": "移行先アプリを起動中…",
"migration_progress_complete": "移行完了:{count} 件を処理しました。",
"migration_progress_failed": "移行失敗:{error}",
"migration_progress_busy": "移行中です。完了までお待ちください。",
},
"ko": {
"migration_progress_title": "가져오기 진행률",
"migration_log_title": "실시간 로그",
"migration_progress_ready": "항목을 선택하고 가져오기를 시작하면 실시간 진행률이 표시됩니다.",
"migration_progress_preparing": "{count}개 항목 가져오기 준비 중…",
"migration_progress_connecting": "대상 앱에 연결 중…",
"migration_progress_item": "가져오는 중 {current}/{total}: {title}",
"migration_progress_finalizing": "인덱스를 기록하고 마무리하는 중…",
"migration_progress_opening": "대상 앱을 여는 중…",
"migration_progress_complete": "가져오기 완료: {count}개 항목을 처리했습니다.",
"migration_progress_failed": "가져오기 실패: {error}",
"migration_progress_busy": "가져오는 중입니다. 완료될 때까지 기다려 주세요.",
},
}
for _lang, _items in _MIGRATION_PROGRESS_TRANSLATIONS.items():
TRANSLATIONS[_lang].update(_items)
TRANSLATIONS["zh-CN"].update({
"migration_dsh_not_running": "没有检测到可连接的 DeepSeek Harness。请先启动 DeepSeek Harness 桌面版,再点击刷新或重新导入。",
"migration_connection_refused": "无法连接 {url}。DeepSeek Harness 可能尚未启动,或本次启动使用了其他端口。",
"migration_native_unavailable": "没有找到 DeepSeek Harness 自带的本地导入组件。请启动桌面版后重试。",
"migration_native_failed": "原生历史导入失败:{error}",
"migration_native_timeout": "原生历史导入等待超时。已停止本次操作,请重试;如果仍然出现,请先让 DeepSeek Harness 中正在运行的任务结束。",
"migration_no_history": "这个会话没有可导入的用户或助手消息。",
})
TRANSLATIONS["en"].update({
"migration_dsh_not_running": "No reachable DeepSeek Harness instance was detected. Start the desktop app, then refresh or import again.",
"migration_connection_refused": "Could not connect to {url}. DeepSeek Harness may not be running or may be using a different port.",
"migration_native_unavailable": "The local import components bundled with DeepSeek Harness were not found. Start the desktop app and try again.",
"migration_native_failed": "Native history import failed: {error}",
"migration_native_timeout": "Native history import timed out and was stopped. Try again; if it persists, let any running DeepSeek Harness task finish first.",
"migration_no_history": "This session has no user or assistant messages to import.",
})
TRANSLATIONS["ja"].update({
"migration_dsh_not_running": "接続可能な DeepSeek Harness が見つかりません。デスクトップ版を起動してから更新または再移行してください。",
"migration_connection_refused": "{url} に接続できません。DeepSeek Harness が未起動か、別のポートを使用している可能性があります。",
"migration_native_unavailable": "DeepSeek Harness 付属のローカル移行コンポーネントが見つかりません。デスクトップ版を起動して再試行してください。",
"migration_native_failed": "ネイティブ履歴の移行に失敗しました:{error}",
"migration_native_timeout": "ネイティブ履歴の移行がタイムアウトしたため停止しました。再試行し、続く場合は DeepSeek Harness の実行中タスクを終了してください。",
"migration_no_history": "移行できるユーザーまたはアシスタントのメッセージがありません。",
})
TRANSLATIONS["ko"].update({
"migration_dsh_not_running": "연결 가능한 DeepSeek Harness를 찾지 못했습니다. 데스크톱 앱을 실행한 뒤 새로고침하거나 다시 가져오세요.",
"migration_connection_refused": "{url}에 연결할 수 없습니다. DeepSeek Harness가 실행 중이 아니거나 다른 포트를 사용 중일 수 있습니다.",
"migration_native_unavailable": "DeepSeek Harness에 포함된 로컬 가져오기 구성 요소를 찾지 못했습니다. 데스크톱 앱을 실행한 뒤 다시 시도하세요.",
"migration_native_failed": "기본 기록 가져오기 실패: {error}",
"migration_native_timeout": "기본 기록 가져오기가 시간 초과되어 중지되었습니다. 다시 시도하고, 계속되면 DeepSeek Harness에서 실행 중인 작업을 먼저 끝내세요.",
"migration_no_history": "가져올 사용자 또는 어시스턴트 메시지가 없습니다.",
})
def set_language(code: str) -> None:
global CURRENT_LANGUAGE
CURRENT_LANGUAGE = code if code in LANGUAGE_LABELS else "zh-CN"
def tr(key: str, **values) -> str:
text = TRANSLATIONS.get(CURRENT_LANGUAGE, {}).get(key)
if text is None and CURRENT_LANGUAGE != "zh-CN":
text = TRANSLATIONS["en"].get(key)
if text is None:
text = {
"subtitle": "Provider switching & history sync", "language_label": "语言", "current_connection": "当前连接", "refresh": "↻ 刷新",
"path_settings": "路径设置", "provider_profiles": "Provider 配置", "profile_count": "{count} 个配置",
"new_profile": "+ 新建配置", "edit": "编辑", "delete": "删除", "type": "类型", "model": "模型",
"source": "配置来源", "status": "状态", "official_account": "官方账号", "third_party_api": "第三方 API",
"current": "● 当前", "auto": "自动", "unknown": "未识别", "loading": "正在读取配置…", "detecting": "正在识别…",
"verify_on_switch": "切换时验证第三方接口", "auto_close": "自动关闭 Codex", "restart_after": "完成后重新打开",
"switch_selected": "切换到选中 Provider →", "test_api": "✓ 检测 API", "repair_history": "修复历史索引",
"open_backups": "打开备份目录", "execution_log": "执行日志", "select_profile": "请先选择一个 Provider。",
"builtin_official": "OpenAI 官方账号(原生)", "builtin_login_needed": "OpenAI 官方账号(需重新登录)",
"source_saved_login": "内置 · 已保存登录", "source_login_needed": "内置 · 需要重新登录", "source_local": "本地加密配置",
"read_profiles": "已读取 {count} 个本地 Provider。", "validating": "正在验证目标接口与凭据…",
"closing_codex": "正在关闭 Codex,避免数据库被占用…", "writing_credentials": "正在原子写入配置与账号凭据…",
"syncing_history": "正在统一历史 Provider 并补齐索引…", "switch_complete": "切换完成:扫描 {rollouts} 个历史,改写 {changed} 个,修复 {updated} 条,补回 {inserted} 条。",
"official_login_ready": "已恢复 Codex 原生官方模式;打开 Codex 后请登录 OpenAI 官方账号。", "rolling_back": "发生错误,正在自动回滚…",
"restarting_codex": "正在重新打开 Codex…", "scan_progress": "已扫描 {index}/{total} 个历史文件…", "codex_closed": "Codex 相关进程已安全退出。",
"imported_profile": "已将当前第三方配置「{name}」加密导入 CodexShift。", "skip_snapshot": "跳过当前配置快照:{error}",
"operation_failed": "失败:{error}", "testing_profile": "正在检测「{name}」的 API…", "api_passed": "API 检测通过:{result}",
"repair_complete": "修复完成:{stats}", "profile_deleted": "已删除本地配置「{name}」。", "home_changed": "Codex Home 已改为:{path}",
"home_auto": "已恢复自动识别 Codex Home:{path}", "choose_home": "选择 Codex Home…", "reset_home": "恢复自动识别",
"new_dialog": "新建第三方 API 配置", "edit_dialog": "修改第三方 API 配置", "profile_name": "配置名称", "model_name": "模型名称",
"fetch_models": "自动获取模型", "cancel": "取消", "save_profile": "保存配置", "save_changes": "保存修改",
"new_hint": "填写后可以先检测 API,不会修改当前 Codex 配置。", "edit_hint": "API Key 留空会保留原密钥;保存后重新切换才会应用。",
"saved_profile": "已{action}第三方配置「{name}」,凭据已加密。", "created": "新建", "updated_action": "更新",
}.get(key, key)
try:
return text.format(**values)
except (KeyError, ValueError):
return text
def preferred_language() -> str:
saved = read_json(app_data_dir() / "settings.json", {}).get("language")
if saved in LANGUAGE_LABELS:
return str(saved)
code = (locale.getlocale()[0] or os.environ.get("LANG") or "").lower()
if code.startswith("ja"):
return "ja"
if code.startswith("ko"):
return "ko"
if code.startswith("zh"):
return "zh-CN"
return "en"
class SwitchError(RuntimeError):
pass
class ModelDiscoveryUnavailable(SwitchError):
pass
def user_home() -> Path:
return Path(os.environ.get("USERPROFILE") or Path.home())
def resource_path(relative: str) -> Path:
return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) / relative
def default_codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or user_home() / ".codex")
def configured_codex_home() -> Path:
state = read_json(app_data_dir() / "settings.json", {})
saved = state.get("codex_home")
if state.get("codex_home_manual") and isinstance(saved, str) and saved.strip():
return Path(saved).expanduser()
return default_codex_home()
def app_data_dir() -> Path:
if IS_MACOS:
root = user_home() / "Library" / "Application Support"
elif IS_WINDOWS:
root = Path(os.environ.get("APPDATA") or user_home() / "AppData" / "Roaming")
else:
root = Path(os.environ.get("XDG_CONFIG_HOME") or user_home() / ".config")
target = root / "CodexShift"
legacy = root / "XuanCodexSwitcher"
if not target.exists() and legacy.exists():
try:
legacy.rename(target)
except OSError:
return legacy
return target
def atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f".{path.name}.codexshift-{os.getpid()}.tmp")
with temp.open("wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(temp, path)
def read_json(path: Path, default):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return default
def write_json(path: Path, value) -> None:
atomic_write(path, json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8"))
def _split_toml(text: str) -> tuple[list[str], dict[str, list[str]], list[str]]:
root: list[str] = []
tables: dict[str, list[str]] = {}
order: list[str] = []
current: list[str] = root
for raw in text.replace("\r\n", "\n").splitlines():
line = raw.rstrip()
match = re.match(r"^\s*(\[\[?.+?\]\]?)\s*(?:#.*)?$", line)
if match:
header = match.group(1)
if header not in tables:
tables[header] = [line]
order.append(header)
else:
# Array-of-table duplicates are kept as one logical segment.
tables[header].append(line)
current = tables[header]
else:
current.append(line)
return root, tables, order
def _assignment_key(line: str) -> str | None:
match = re.match(r'^\s*((?:"[^"]+"|\'[^\']+\'|[A-Za-z0-9_.-])+?)\s*=', line)
return match.group(1).strip() if match else None
def merge_toml(base: str, overlay: str) -> str:
"""Merge TOML at root-key/table granularity while keeping human formatting."""
base_root, base_tables, base_order = _split_toml(base)
over_root, over_tables, over_order = _split_toml(overlay)
over_keys = {_assignment_key(line) for line in over_root}
over_keys.discard(None)
root = [line for line in base_root if _assignment_key(line) not in over_keys]
if root and any(line.strip() for line in root) and over_root:
root.append("")
root.extend(over_root)
tables = dict(base_tables)
order = list(base_order)
for header in over_order:
tables[header] = over_tables[header]
if header not in order:
order.append(header)
pieces = ["\n".join(root).strip()]
pieces.extend("\n".join(tables[header]).strip() for header in order)
result = "\n\n".join(piece for piece in pieces if piece) + "\n"
tomllib.loads(result)
return result
def native_official_config(config_text: str) -> str:
"""Keep unrelated Codex preferences while restoring the built-in OpenAI provider."""
try:
root, tables, order = _split_toml(config_text)
remove_root = {"model", "model_provider", "disable_response_storage"}
root = [line for line in root if _assignment_key(line) not in remove_root]
root.insert(0, f'model_provider = "{OFFICIAL_PROVIDER}"')
official_table = re.compile(r"^\[model_providers\.(?:openai|\"openai\"|'openai')\]$")
order = [header for header in order if not official_table.fullmatch(header.strip())]
pieces = ["\n".join(root).strip()]
pieces.extend("\n".join(tables[header]).strip() for header in order)
result = "\n\n".join(piece for piece in pieces if piece) + "\n"
tomllib.loads(result)
return result
except (ValueError, TypeError, tomllib.TOMLDecodeError):
return f'model_provider = "{OFFICIAL_PROVIDER}"\n'
def effective_model_provider(config_text: str) -> str:
data = tomllib.loads(config_text)
return str(data.get("model_provider") or OFFICIAL_PROVIDER)
def provider_base_url(config_text: str) -> str | None:
data = tomllib.loads(config_text)
selected = str(data.get("model_provider") or OFFICIAL_PROVIDER)
provider = data.get("model_providers", {}).get(selected, {})
value = provider.get("base_url") if isinstance(provider, dict) else None
return str(value).strip() if value else None
def provider_model(config_text: str) -> str | None:
value = tomllib.loads(config_text).get("model")
return str(value) if value else None
def provider_name_from_config(config_text: str, fallback: str) -> str:
try:
data = tomllib.loads(config_text)
selected = str(data.get("model_provider") or OFFICIAL_PROVIDER)
provider = data.get("model_providers", {}).get(selected, {})
name = provider.get("name") if isinstance(provider, dict) else None
return str(name).strip() if name else fallback
except (TypeError, tomllib.TOMLDecodeError):
return fallback
def custom_provider_config(base_config: str, name: str, base_url: str, model: str) -> str:
q = lambda value: json.dumps(value, ensure_ascii=False)
overlay = (
'model_provider = "custom"\n'
f"model = {q(model)}\n"
"disable_response_storage = true\n\n"
"[model_providers.custom]\n"
f"name = {q(name)}\n"
f"base_url = {q(base_url)}\n"
'wire_api = "responses"\n'
"requires_openai_auth = true\n"
"request_max_retries = 4\n"
"stream_max_retries = 5\n"
)
return merge_toml(base_config, overlay)
def provider_signature(config_text: str, auth: dict) -> tuple[str, str, str, str] | None:
try:
return (
effective_model_provider(config_text),
provider_base_url(config_text) or "",
provider_model(config_text) or "",
str(auth.get("OPENAI_API_KEY") or ""),
)
except (TypeError, tomllib.TOMLDecodeError):
return None
def provider_identity(config_text: str, auth: dict) -> tuple[str, str, str] | None:
"""Identify one API account independently of its currently selected model."""
signature = provider_signature(config_text, auth)
if signature is None:
return None
provider, base_url, _model, api_key = signature
return provider.casefold(), base_url.rstrip("/").casefold(), api_key
def normalize_auth(auth: dict, target_provider: str) -> dict:
result = dict(auth or {})
is_chatgpt_account = result.get("auth_mode") == "chatgpt" or (
isinstance(result.get("tokens"), dict) and bool(result["tokens"].get("refresh_token"))
)
if is_chatgpt_account:
if not isinstance(result.get("tokens"), dict) or not result["tokens"].get("refresh_token"):
raise SwitchError("官方账号凭据不完整,请先在 Codex 中登录一次官方账号。")
result["auth_mode"] = "chatgpt"
result["OPENAI_API_KEY"] = None
else:
key = result.get("OPENAI_API_KEY")
if not isinstance(key, str) or not key.strip():
raise SwitchError("第三方 Provider 没有可用的 OPENAI_API_KEY。")
# 统一为 Codex 可识别的 API Key 登录结构,避免残留旧账号字段。
result = {"OPENAI_API_KEY": key.strip(), "auth_mode": "apikey"}
return result
def redact(value):
if isinstance(value, dict):
return {k: ("***" if k in SECRET_KEYS else redact(v)) for k, v in value.items()}
if isinstance(value, list):
return [redact(v) for v in value]
return value
@dataclass
class Provider:
id: str
name: str
config: str
auth: dict
is_current: bool = False
source: str = "本地配置"
@property
def model_provider(self) -> str:
return effective_model_provider(self.config)
@property
def is_official(self) -> bool:
return self.id == BUILTIN_OFFICIAL_ID or (
self.model_provider == OFFICIAL_PROVIDER and self.auth.get("auth_mode") == "chatgpt"
)
@property
def kind_label(self) -> str:
return tr("official_account") if self.is_official else tr("third_party_api")
class DpapiVault:
"""Encrypted per-user snapshots: DPAPI on Windows, Keychain on macOS."""
def __init__(self, path: Path):
self.path = path
def _keychain_account(self) -> str:
vault_id = hashlib.sha256(str(self.path.resolve()).encode("utf-8")).hexdigest()[:20]
return f"{getpass.getuser()}:{vault_id}"
@staticmethod
def _crypt(data: bytes, protect: bool) -> bytes:
if os.name != "nt":
# Test/development fallback. Production builds target Windows.
return base64.b64encode(data) if protect else base64.b64decode(data)
class DATA_BLOB(ctypes.Structure):
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
buffer = ctypes.create_string_buffer(data, len(data))
in_blob = DATA_BLOB(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
out_blob = DATA_BLOB()
if protect:
ok = ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(in_blob), "CodexShift", None, None, None, 0, ctypes.byref(out_blob)
)
else:
ok = ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)
)
if not ok:
raise ctypes.WinError()
try:
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
def load(self) -> dict:
if IS_MACOS:
try:
account = self._keychain_account()
result = subprocess.run(
["security", "find-generic-password", "-s", APP_NAME, "-a", account, "-w"],
capture_output=True, text=True, check=True,
)
return json.loads(base64.b64decode(result.stdout.strip()).decode("utf-8"))
except (OSError, subprocess.CalledProcessError, ValueError, UnicodeDecodeError):
pass
if not self.path.exists():
return {}
try:
return json.loads(self._crypt(self.path.read_bytes(), False).decode("utf-8"))
except Exception:
return {}
def save(self, value: dict) -> None:
raw = json.dumps(value, ensure_ascii=False).encode("utf-8")
if IS_MACOS:
account = self._keychain_account()
encoded = base64.b64encode(raw).decode("ascii")
try:
subprocess.run(
["security", "add-generic-password", "-U", "-s", APP_NAME,
"-a", account, "-w", encoded],
capture_output=True, text=True, check=True,
)
return
except (OSError, subprocess.CalledProcessError) as exc:
raise SwitchError(f"无法写入 macOS Keychain:{exc}") from exc
atomic_write(self.path, self._crypt(raw, True))
class Backup:
def __init__(self, codex_home: Path):
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
self.codex_home = codex_home.resolve()
self.root = (self.codex_home / "switcher_backups" / stamp).resolve()
self.root.mkdir(parents=True, exist_ok=False)
self.files: list[str] = []
def save(self, path: Path) -> None:
path = path.resolve()
if not path.exists() or path.is_dir():
return
try:
relative = path.relative_to(self.codex_home)
except ValueError as exc:
raise SwitchError(f"拒绝备份 Codex Home 之外的文件:{path}") from exc
key = relative.as_posix()
if key in self.files:
return
target = self.root / "files" / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
self.files.append(key)
def finish(self, provider_id: str) -> None:
write_json(self.root / "manifest.json", {"provider_id": provider_id, "files": self.files})
def restore(self) -> None:
# A newer WAL must never be replayed on top of the restored main DB.
db_key = "state_5.sqlite"
if db_key in self.files:
for suffix in ("-wal", "-shm"):
sidecar = self.codex_home / f"state_5.sqlite{suffix}"
try:
sidecar.unlink(missing_ok=True)
except OSError:
pass
for key in reversed(self.files):
source = self.root / "files" / Path(key)
target = self.codex_home / Path(key)
if source.exists():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def prune_backups(codex_home: Path, keep: int = 3) -> None:
root = (codex_home / "switcher_backups").resolve()
if not root.exists():
return
items = sorted((p.resolve() for p in root.iterdir() if p.is_dir()), reverse=True)
for path in items[keep:]:
if path.parent == root and (path / "manifest.json").exists():
shutil.rmtree(path)
def rollout_files(codex_home: Path) -> list[Path]:
result: list[Path] = []
for folder in (codex_home / "sessions", codex_home / "archived_sessions"):
if folder.exists():
result.extend(path for path in folder.rglob("*.jsonl") if path.is_file())
return sorted(set(result))
def _rewrite_rollout(path: Path, target_provider: str, backup: Backup | None) -> tuple[bool, dict | None]:
output: list[bytes] = []
changed = False
session_meta = None
with path.open("rb") as fh:
for raw in fh:
try:
obj = json.loads(raw)
except (ValueError, UnicodeDecodeError):
output.append(raw)
continue
payload = obj.get("payload")
if isinstance(payload, dict) and obj.get("type") == "session_meta" and session_meta is None:
session_meta = dict(payload)
if isinstance(payload, dict) and obj.get("type") == "session_meta" and not str(payload.get("originator") or "").strip():
payload["originator"] = "CodexShift"
changed = True
if isinstance(payload, dict) and "model_provider" in payload:
if payload["model_provider"] != target_provider:
payload["model_provider"] = target_provider
changed = True
if obj.get("type") == "session_meta" and session_meta is not None:
session_meta["model_provider"] = target_provider
output.append((json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"))
else:
output.append(raw)
if changed:
if backup:
backup.save(path)
atomic_write(path, b"".join(output))
return changed, session_meta
def _timestamp(value, fallback: float) -> int:
if isinstance(value, str):
try:
return int(dt.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
except ValueError:
pass
return int(fallback)
def _is_environment_instruction(text: str) -> bool:
"""Hide injected workspace rules from migration titles and previews."""
lowered = text.casefold().strip()
markers = (
"# agents.md instructions",
"<instructions>",
"<environment_context>",
"the following is the codex agent history",
"reviewed codex session id:",
"imported from deepseek harness:",
"you are codex",
"你叫小玄",
"current runtime context. this snapshot supersedes",
"<system-reminder>",
)
return any(marker in lowered for marker in markers)
def _extract_user_request(text: str) -> str:
"""Extract the visible request from Codex's attachment and ambient-context wrapper."""
value = str(text or "").replace("\r\n", "\n").strip()
for marker in ("## My request:", "## My request:", "## 我的请求:", "## 我的请求:"):
if marker in value:
value = value.rsplit(marker, 1)[1].strip()
break
if value.casefold().startswith("# files mentioned by the user:"):
return ""
value = re.sub(r"<image\b[^>]*>(?:\s*</image>)?", "", value, flags=re.IGNORECASE)
value = re.sub(r"\n{3,}", "\n\n", value).strip()
if not value or _is_environment_instruction(value):
return ""
return value
def _first_user_message(path: Path) -> str:
try:
with path.open(encoding="utf-8") as fh:
for line in fh:
obj = json.loads(line)
payload = obj.get("payload", {})
if obj.get("type") == "response_item" and payload.get("role") == "user":
parts = payload.get("content") or []
text = " ".join(str(p.get("text", "")) for p in parts if isinstance(p, dict)).strip()
if text:
# Older CodexShift releases inserted a synthetic first
# message. Ignore it when rebuilding the visible title.
if text.startswith("Imported from DeepSeek Harness:"):
continue
request = _extract_user_request(text)
if not request:
continue
return re.sub(r"\s+", " ", request)[:120]
except Exception:
pass
return "已恢复的 Codex 任务"
def _rollout_title(path: Path, meta: dict) -> str:
"""Return a useful title while keeping imported DSH titles intact."""
candidate = str(meta.get("title") or "").strip()
if candidate and not candidate.startswith("session-"):
return candidate[:120]
return _first_user_message(path)
def sync_history(
codex_home: Path,
target_provider: str,
backup: Backup | None,
log: Callable[[str], None] = lambda _x: None,
*,
paths: Iterable[Path] | None = None,
rewrite_existing: bool = True,
) -> dict:
selected_paths = rollout_files(codex_home) if paths is None else sorted({Path(path) for path in paths})
metas: dict[str, tuple[Path, dict, bool]] = {}
changed = 0
for index, path in enumerate(selected_paths, 1):
was_changed, meta = _rewrite_rollout(path, target_provider, backup)
changed += int(was_changed)
if meta and not meta.get("parent_thread_id"):
thread_id = str(meta.get("id") or meta.get("session_id") or "")
if thread_id:
archived = "archived_sessions" in path.parts
metas[thread_id] = (path, meta, archived)
if index % 50 == 0:
log(tr("scan_progress", index=index, total=len(selected_paths)))
db_path = codex_home / "state_5.sqlite"
updated = inserted = 0
if db_path.exists():
if backup:
backup.save(db_path)
with closing(sqlite3.connect(db_path, timeout=30)) as con:
con.execute("PRAGMA busy_timeout=30000")
check = con.execute("PRAGMA integrity_check").fetchone()
if not check or check[0] != "ok":
raise SwitchError(f"历史数据库完整性检查失败:{check[0] if check else 'unknown'}")
if rewrite_existing:
updated = con.execute(
"UPDATE threads SET model_provider=? WHERE model_provider<>?",
(target_provider, target_provider),
).rowcount
existing = {str(row[0]) for row in con.execute("SELECT id FROM threads")}
for thread_id, (path, meta, archived) in metas.items():
if thread_id in existing:
con.execute(
"UPDATE threads SET rollout_path=?, model_provider=?, archived=? WHERE id=?",
(str(path), target_provider, int(archived), thread_id),
)
continue
stat = path.stat()
created = _timestamp(meta.get("timestamp"), stat.st_ctime)
updated_at = int(stat.st_mtime)
title = _rollout_title(path, meta)
cwd = str(meta.get("cwd") or user_home())
source = meta.get("source")
if not isinstance(source, str):
source = "vscode"
con.execute(
"INSERT INTO threads "
"(id,rollout_path,created_at,updated_at,source,model_provider,cwd,title,"
"sandbox_policy,approval_mode,has_user_event,archived,cli_version,thread_source,"
"first_user_message,created_at_ms,updated_at_ms,preview,recency_at,recency_at_ms) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
thread_id, str(path), created, updated_at, source, target_provider, cwd, title,
"{}", "on-request", 1, int(archived), str(meta.get("cli_version") or ""),
str(meta.get("thread_source") or "user"), title, created * 1000,
updated_at * 1000, title, updated_at, updated_at * 1000,
),
)
inserted += 1
con.commit()
check = con.execute("PRAGMA integrity_check").fetchone()
if not check or check[0] != "ok":
raise SwitchError("写入后历史数据库完整性检查失败。")
return {"rollouts": len(selected_paths), "changed": changed, "updated": updated, "inserted": inserted}
def register_codex_imports(
codex_home: Path,
threads: Iterable[tuple[str, str, str]],
projects: Iterable[MigrationProject] = (),
backup: Backup | None = None,
) -> dict:
"""Make imported rollouts visible in Codex's project navigator and DB."""
state_path = codex_home / ".codex-global-state.json"
if backup:
backup.save(state_path)
state = read_json(state_path, {})
if not isinstance(state, dict):
state = {}
local_projects = state.setdefault("local-projects", {})
if not isinstance(local_projects, dict):
local_projects = {}
state["local-projects"] = local_projects
project_order = state.setdefault("project-order", [])
if not isinstance(project_order, list):
project_order = []
state["project-order"] = project_order
assignments = state.setdefault("thread-project-assignments", {})
if not isinstance(assignments, dict):
assignments = {}
state["thread-project-assignments"] = assignments
projectless = state.setdefault("projectless-thread-ids", [])
if not isinstance(projectless, list):
projectless = []
state["projectless-thread-ids"] = projectless
project_ids: dict[str, str] = {}
for project in projects:
path = _display_project_path(project.path)
if not path:
continue
key = _canonical_project_path(path)
found = next((str(pid) for pid, saved in local_projects.items()
if isinstance(saved, dict) and any(_canonical_project_path(str(root)) == key
for root in (saved.get("rootPaths") or []))), None)
if not found:
found = f"local-{hashlib.sha256(key.encode('utf-8')).hexdigest()[:32]}"
local_projects[found] = {
"id": found, "name": project.name or _project_basename(path),
"rootPaths": [path], "createdAt": int(time.time() * 1000),
"updatedAt": int(time.time() * 1000),
}
project_order.append(found)
project_ids[key] = found
normalized_threads = list(threads)
for thread_id, title, project_path in normalized_threads:
path = _display_project_path(project_path)
key = _canonical_project_path(path)
project_id = project_ids.get(key)
if not project_id:
project_id = f"local-{hashlib.sha256(key.encode('utf-8')).hexdigest()[:32]}"
saved = next((saved for saved in local_projects.values() if isinstance(saved, dict)
and any(_canonical_project_path(str(root)) == key for root in (saved.get("rootPaths") or []))), None)
if saved:
project_id = str(saved.get("id") or project_id)
else: