mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-07 22:28:26 +08:00
Wire hermes through SkillApps struct, DAO SQL, command parser, and SKILLS_APP_IDS. Add a Skills entry to the Hermes toolbar. Simplify skill_sync test fixtures to use SkillApps::default().
381 lines
12 KiB
Rust
381 lines
12 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().expect("acquire test mutex");
|
|
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 sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
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().expect("acquire test mutex");
|
|
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().expect("acquire test mutex");
|
|
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().expect("acquire test mutex");
|
|
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().expect("acquire test mutex");
|
|
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"
|
|
);
|
|
}
|