mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-23 23:59:24 +08:00
Skills import previously inferred app enablement from filesystem presence, causing incorrect multi-app activation when the same skill directory existed under multiple app paths. Now the frontend submits explicit app selections via ImportSkillSelection, and schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction. Also adds reconciliation to sync_to_app (removes disabled/orphaned symlinks) and MCP sync_all_enabled (removes disabled servers from live config).
174 lines
5.2 KiB
Rust
174 lines
5.2 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 {
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: true,
|
|
},
|
|
}],
|
|
)
|
|
.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 {
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: false,
|
|
},
|
|
installed_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 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"
|
|
);
|
|
}
|