Files
Jason 0e6430abdb feat(codex): switch Codex by replacing only key fields
Switching, adding, editing, sync, universal providers and entering or
leaving local routing now write Codex through the write engine. Only
routing, credential, model, reasoning, context-window and compatibility
fields are replaced; MCP servers, [projects], comments and the user's own
provider tables stay byte-for-byte. auth.json, config.toml, the model
catalog, the managed-account marker and a device-local login stash commit
as one operation, so a crash at any point rolls forward on startup.

- Third-party routes are always written as [model_providers.custom] with
  the key in experimental_bearer_token. Rows that use another id, a
  top-level openai_base_url or a legacy reserved table are normalized on
  the way out. Switching to an official card leaves a dormant custom table
  (local proxy address plus placeholder credential) so older third-party
  sessions can still resume.
- requires_openai_auth follows the route's auth and whether a login is on
  disk; keyring and ephemeral credential stores are handled separately.
- A switch is refused before anything is written when the active profile
  overrides the route, the selected table is missing or built in, a
  keyless row would fall back to the official login, or the row has no
  auth.
- Provider tables an older version wrote are removed only when they can
  be proven to be CC Switch's. Tables a profile references are kept, and
  reserved ids that cannot be proven are renamed.
- Provider-exclusive fields, including web_search and a row's own
  model_catalog_json, are removed on the way out only when live still
  holds the previous provider's value.
- No more backfill. A native ChatGPT login that CC Switch deletes or
  overwrites is kept in ~/.cc-switch/codex-login-stash.json (0600, not
  synced) and restored when switching back to an official card; logging
  out on an official card discards it. Login snapshots already in
  official rows seed the stash once. A leftover API key in auth.json is
  deleted only when it matches a third-party row's key.
- Managed accounts fetch their token before taking the lock. If the CLI
  refreshes the login during a switch, the switch stops before writing
  anything. Once files are published, a later failure no longer rolls the
  row back; startup finishes the switch.
- Local routing: third-party routes get a fixed local-proxy table and the
  official route uses cc-switch-official plus the client's own login. The
  contract covers every field written, so a route switch with the same
  contract touches no files.
- The Codex editor shows what config.toml will look like after switching.
  On save, key fields go to the row and other edits go to live as global
  settings with a three-way check. Row fields that never reach live are
  listed read-only and can be copied.
- The Codex common-config snippet is frozen like Claude's; its UI and the
  TOML snippet command are removed. New Codex rows keep
  commonConfigEnabled: true for older versions that sync them.
- The write engine can delete a file as part of an operation.
- Integration-test mutexes tolerate poisoning so one failure no longer
  cascades into unrelated tests.
2026-09-27 22:38:42 +08:00

428 lines
14 KiB
Rust

use std::fs;
use cc_switch_lib::{
migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
fn write_skill(dir: &std::path::Path, name: &str) {
fs::create_dir_all(dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
#[cfg(unix)]
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
std::os::unix::fs::symlink(src, dest).expect("create symlink");
}
#[cfg(windows)]
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
std::os::windows::fs::symlink_dir(src, dest).expect("create symlink");
}
#[test]
fn import_from_apps_respects_explicit_app_selection() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
write_skill(
&home.join(".claude").join("skills").join("shared-skill"),
"Shared",
);
write_skill(
&home
.join(".config")
.join("opencode")
.join("skills")
.join("shared-skill"),
"Shared",
);
let state = create_test_state().expect("create test state");
let imported = SkillService::import_from_apps(
&state.db,
vec![ImportSkillSelection {
directory: "shared-skill".to_string(),
apps: SkillApps {
opencode: true,
..Default::default()
},
}],
)
.expect("import skills");
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
let skill = imported.first().expect("imported skill");
assert!(
skill.apps.opencode,
"explicitly selected OpenCode app should remain enabled"
);
assert!(
!skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,
"import should no longer infer apps from every matching source path"
);
}
#[test]
fn import_from_apps_does_not_rewrite_selected_app_directory() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("codex-skill");
write_skill(&ssot_skill_dir, "Stale SSOT Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "stale ssot").expect("write stale ssot prompt");
let codex_skill_dir = home.join(".codex").join("skills").join("codex-skill");
write_skill(&codex_skill_dir, "Live Codex Skill");
fs::write(codex_skill_dir.join("prompt.md"), "live codex").expect("write live codex prompt");
let state = create_test_state().expect("create test state");
let imported = SkillService::import_from_apps(
&state.db,
vec![ImportSkillSelection {
directory: "codex-skill".to_string(),
apps: SkillApps {
codex: true,
..Default::default()
},
}],
)
.expect("import skills");
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
assert!(
imported[0].apps.codex,
"import should preserve the selected Codex app state"
);
assert_eq!(
fs::read_to_string(codex_skill_dir.join("prompt.md")).expect("read live codex prompt"),
"live codex",
"import should not replace the app skill directory with SSOT contents"
);
assert!(
!fs::symlink_metadata(&codex_skill_dir)
.expect("read codex skill metadata")
.file_type()
.is_symlink(),
"import should not replace the app skill directory with a managed symlink"
);
}
#[test]
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let ssot_dir = home.join(".cc-switch").join("skills");
let disabled_skill = ssot_dir.join("disabled-skill");
let orphan_skill = ssot_dir.join("orphan-skill");
write_skill(&disabled_skill, "Disabled");
write_skill(&orphan_skill, "Orphan");
let opencode_skills_dir = home.join(".config").join("opencode").join("skills");
fs::create_dir_all(&opencode_skills_dir).expect("create opencode skills dir");
symlink_dir(&disabled_skill, &opencode_skills_dir.join("disabled-skill"));
symlink_dir(&orphan_skill, &opencode_skills_dir.join("orphan-skill"));
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:disabled-skill".to_string(),
name: "Disabled".to_string(),
description: None,
directory: "disabled-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::default(),
installed_at: 0,
content_hash: None,
updated_at: 0,
})
.expect("save disabled skill");
SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect("reconcile skills");
assert!(
!opencode_skills_dir.join("disabled-skill").exists(),
"DB-known disabled skill should be removed from OpenCode live dir"
);
assert!(
!opencode_skills_dir.join("orphan-skill").exists(),
"orphaned symlink into SSOT should be cleaned up"
);
}
#[test]
fn uninstall_skill_creates_backup_before_removing_ssot() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
write_skill(&ssot_skill_dir, "Backup Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:backup-skill".to_string(),
name: "Backup Skill".to_string(),
description: Some("Back me up before uninstall".to_string()),
directory: "backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 123,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
let backup_path = result.backup_path.expect("backup path should be returned");
let backup_dir = std::path::PathBuf::from(&backup_path);
assert!(backup_dir.exists(), "backup directory should exist");
assert!(
backup_dir.join("skill").join("SKILL.md").exists(),
"backup should include SKILL.md"
);
assert_eq!(
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
.expect("read backed up prompt"),
"backup me"
);
let metadata: serde_json::Value = serde_json::from_str(
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
)
.expect("parse backup metadata");
assert_eq!(metadata["skill"]["directory"], "backup-skill");
assert_eq!(metadata["skill"]["name"], "Backup Skill");
assert!(
!ssot_skill_dir.exists(),
"SSOT skill directory should be removed after uninstall"
);
assert!(
state
.db
.get_installed_skill("local:backup-skill")
.expect("query skill")
.is_none(),
"database row should be deleted after uninstall"
);
}
#[test]
fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("restore-skill");
write_skill(&ssot_skill_dir, "Restore Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "restore me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:restore-skill".to_string(),
name: "Restore Skill".to_string(),
description: Some("Bring the files back".to_string()),
directory: "restore-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 456,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:restore-skill").expect("uninstall skill");
let backup_id = std::path::Path::new(
&uninstall
.backup_path
.expect("backup path should be returned on uninstall"),
)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude)
.expect("restore from backup");
assert_eq!(restored.directory, "restore-skill");
assert!(restored.apps.claude, "restored skill should enable Claude");
assert!(
!restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode,
"restore should only enable the selected app"
);
assert!(
home.join(".cc-switch")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should exist in SSOT"
);
assert!(
home.join(".claude")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should sync to the selected app"
);
assert!(
state
.db
.get_installed_skill("local:restore-skill")
.expect("query restored skill")
.is_some(),
"restored skill should be written back to the database"
);
}
#[test]
fn delete_skill_backup_removes_backup_directory() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home
.join(".cc-switch")
.join("skills")
.join("delete-backup-skill");
write_skill(&ssot_skill_dir, "Delete Backup Skill");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:delete-backup-skill".to_string(),
name: "Delete Backup Skill".to_string(),
description: Some("Remove my backup".to_string()),
directory: "delete-backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 789,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:delete-backup-skill").expect("uninstall skill");
let backup_path = uninstall
.backup_path
.expect("backup path should be returned on uninstall");
let backup_id = std::path::Path::new(&backup_path)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
assert!(
std::path::Path::new(&backup_path).exists(),
"backup directory should exist before deletion"
);
SkillService::delete_backup(&backup_id).expect("delete backup");
assert!(
!std::path::Path::new(&backup_path).exists(),
"backup directory should be removed"
);
assert!(
SkillService::list_backups()
.expect("list backups")
.into_iter()
.all(|entry| entry.backup_id != backup_id),
"deleted backup should no longer appear in backup list"
);
}
#[test]
fn migration_snapshot_overrides_multi_source_directory_inference() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
write_skill(
&home.join(".claude").join("skills").join("demo-skill"),
"Demo",
);
write_skill(
&home
.join(".config")
.join("opencode")
.join("skills")
.join("demo-skill"),
"Demo",
);
let state = create_test_state().expect("create test state");
state
.db
.set_setting(
"skills_ssot_migration_snapshot",
r#"[{"directory":"demo-skill","app_type":"claude"}]"#,
)
.expect("seed migration snapshot");
let count = migrate_skills_to_ssot(&state.db).expect("migrate skills to ssot");
assert_eq!(count, 1, "expected one migrated skill");
let skills = state.db.get_all_installed_skills().expect("get skills");
let migrated = skills
.values()
.find(|skill| skill.directory == "demo-skill")
.expect("migrated demo-skill");
assert!(
migrated.apps.claude,
"legacy snapshot should preserve Claude enablement"
);
assert!(
!migrated.apps.opencode,
"migration should no longer infer OpenCode enablement from a duplicate directory alone"
);
}