fix: replace implicit app inference with explicit selection for Skills import and sync

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).
This commit is contained in:
Jason
2026-03-14 17:20:01 +08:00
parent f1d2c6045b
commit 7097a0d710
13 changed files with 560 additions and 39 deletions
+96 -1
View File
@@ -10,7 +10,9 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
fn import_default_config_claude_persists_provider() {
@@ -560,3 +562,96 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
"~/.claude.json should still not exist after skipped sync"
);
}
#[test]
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
fs::write(
&mcp_path,
serde_json::to_string_pretty(&json!({
"mcpServers": {
"managed-disabled": {
"type": "stdio",
"command": "echo"
},
"external-only": {
"type": "stdio",
"command": "external"
}
}
}))
.expect("serialize claude mcp"),
)
.expect("seed claude mcp");
let state = create_test_state().expect("create test state");
state
.db
.save_mcp_server(&McpServer {
id: "managed-disabled".to_string(),
name: "Managed Disabled".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
})
.expect("save disabled server");
state
.db
.save_mcp_server(&McpServer {
id: "managed-enabled".to_string(),
name: "Managed Enabled".to_string(),
server: json!({
"type": "stdio",
"command": "managed"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
})
.expect("save enabled server");
McpService::sync_all_enabled(&state).expect("reconcile mcp");
let text = fs::read_to_string(&mcp_path).expect("read claude mcp");
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude mcp");
let servers = value
.get("mcpServers")
.and_then(|entry| entry.as_object())
.expect("mcpServers object");
assert!(
!servers.contains_key("managed-disabled"),
"DB-known disabled server should be removed from live config"
);
assert!(
servers.contains_key("managed-enabled"),
"DB-known enabled server should be present in live config"
);
assert!(
servers.contains_key("external-only"),
"live entries unknown to DB should be preserved"
);
}
+173
View File
@@ -0,0 +1,173 @@
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"
);
}
+8 -1
View File
@@ -28,7 +28,14 @@ pub fn ensure_test_home() -> &'static Path {
/// 清理测试目录中生成的配置文件与缓存。
pub fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
for sub in [
".claude",
".codex",
".cc-switch",
".gemini",
".config",
".openclaw",
] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = std::fs::remove_dir_all(&path) {