Marketplace: derive the plugin slug from the id only (final review M4)

The slug (plugin marketplace "triple-c-<slug>", plugin tree
"plugins/<slug>/") was built from the editable display name. After a
rename the next sync registered the new marketplace, skipped the plugin
install because the state's commit matched, then removed the old
marketplace: the plugin was gone while the report said nothing changed.

marketplace_slug now takes the id only ("mp-<id8>"). With plugin state
kept per slug (I1), containers synced with the old "<name>-<id8>" slugs
move over on their next sync: plugins are installed under the new name,
the old copies uninstalled and the old registration dropped. A sync
script test covers that migration (it fails on the pre-I1 script).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:14:30 -07:00
co-authored by Claude Opus 5.5
parent 19ae92d4f8
commit 5829c42f0f
4 changed files with 83 additions and 36 deletions
+29 -3
View File
@@ -200,7 +200,7 @@ pub fn build_payload(input: &PayloadInput) -> Result<Payload, String> {
}
};
entry["source"] = json!(format!("./{key}"));
let slug = marketplace_slug(&m.name, &m.id);
let slug = marketplace_slug(&m.id);
for f in &files {
tar.file(
&format!("plugins/{slug}/{key}/{}", f.rel_path),
@@ -328,7 +328,7 @@ mod tests {
assert!(p.skipped.is_empty(), "{:?}", p.skipped);
let files = unpack(&p.tar);
let slug = marketplace_slug("Team Tools", "m1aaaaaaaa");
let slug = marketplace_slug("m1aaaaaaaa");
for path in [
"agents/code-reviewer.md".to_string(),
"skills/example-skill/SKILL.md".to_string(),
@@ -367,7 +367,7 @@ mod tests {
data_root: data.path(),
})
.unwrap();
let slug = marketplace_slug("Team Tools", "m1aaaaaaaa");
let slug = marketplace_slug("m1aaaaaaaa");
let files = unpack(&p.tar);
let manifest: Value = serde_json::from_slice(&files["manifest.json"].data).unwrap();
@@ -398,6 +398,32 @@ mod tests {
assert_eq!(catalog["plugins"][0]["source"], "./example-plugin");
}
/// Final review M4: the plugin marketplace name comes from the id, so a
/// rename never makes the container see a different marketplace.
#[test]
fn plugin_slug_survives_a_rename() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![inst(ItemKind::Plugin, "example-plugin", &c)];
let slug_named = |name: &str| {
let marketplaces = vec![Marketplace {
name: name.into(),
..market("m1aaaaaaaa")
}];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
p.manifest["items"][0]["slug"].as_str().unwrap().to_string()
};
assert_eq!(slug_named("Team Tools"), "mp-m1aaaaaa");
assert_eq!(slug_named("Renamed"), "mp-m1aaaaaa");
}
#[test]
fn items_that_cannot_be_built_are_skipped_not_fatal() {
let Some(fx) = GitFixture::new() else { return };
@@ -1024,3 +1024,36 @@ fn a_deselected_legacy_plugin_record_is_still_uninstalled() {
);
assert_eq!(read_json(&state_path)["items"], json!({}));
}
/// Final review M4: slugs moved from "<name>-<id8>" to "mp-<id8>". The first
/// sync after that installs under the new name, uninstalls the old copy and
/// drops the old registration; later syncs are quiet.
#[test]
fn a_slug_change_reinstalls_under_the_new_name_and_retires_the_old() {
let Some(env) = env() else { return };
shared_plugin_payload(&env, &[(SLUG, C1)]);
assert_eq!(run(&env).installed, vec!["plugin:p"]);
fs::remove_file(&env.log).unwrap();
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
let r = run(&env);
assert_eq!(r.installed, vec!["plugin:p"], "{r:?}");
assert_eq!(r.removed, vec!["plugin:p"], "{r:?}");
assert!(r.errors.is_empty(), "{r:?}");
let tree = env.home.join(".claude/triple-c/plugins");
assert_eq!(
claude_log(&env),
vec![
format!("plugin marketplace add {}", tree.join(SLUG_A).display()),
format!("plugin install p@triple-c-{SLUG_A}"),
format!("plugin uninstall p@triple-c-{SLUG}"),
format!("plugin marketplace remove triple-c-{SLUG}"),
]
);
assert!(!tree.join(SLUG).exists());
fs::remove_file(&env.log).unwrap();
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
let r = run(&env);
assert!(nothing_reported(&r), "{r:?}");
}