Marketplace sync: track plugin state per marketplace (final review I1)

Two marketplaces shipping a plugin of the same name shared one
"plugin:<key>" state record, so every sync reinstalled one copy and
reported it updated, and removing one marketplace never uninstalled its
copy. Plugin state ids are now "plugin:<slug>/<key>"; the slug and key
for an uninstall are derived from the id and re-validated. Older
"plugin:<key>" records are migrated using their recorded slug, so
existing installs are neither reinstalled nor orphaned. Reports keep
"plugin:<key>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:07:57 -07:00
co-authored by Claude Opus 5.5
parent 2c1d6d8713
commit f2ebddd073
3 changed files with 182 additions and 18 deletions
+39 -17
View File
@@ -92,12 +92,17 @@ claude_cmd() {
fi
}
owned() { jq -e --arg id "$1" '.items | has($id)' "$STATE" >/dev/null 2>&1; }
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$STATE"; }
# Every "<kind>:<key>" the manifest names with string fields, well-formed or
# State ids are "<kind>:<key>", except plugins: "plugin:<slug>/<key>", since
# two marketplaces may ship a plugin of the same name. Reports keep
# "<kind>:<key>" for every kind. $OLD is the state as read at the start
# (legacy "plugin:<key>" records migrated); $STATE is written once, at the end.
OLD="$R/state.json"
owned() { jq -e --arg id "$1" '.items | has($id)' "$OLD" >/dev/null 2>&1; }
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$OLD"; }
# Every state id the manifest names with string fields, well-formed or
# not: a selected item that failed this run must not be removed.
in_manifest() { grep -qxF "$1" "$R/manifest_ids"; }
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$STATE")"; }
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$OLD")"; }
# $1 = installed|updated|none for this id at this commit.
outcome_of() {
p=$(prev_commit "$1")
@@ -182,7 +187,9 @@ if ! {
end
| map("_" + .) | @tsv' "$MANIFEST" >"$R/items.tsv" &&
jq -r '.items[] | objects | select((.kind | type) == "string" and (.key | type) == "string")
| [.kind + ":" + .key] | @tsv' "$MANIFEST" >"$R/manifest_ids" &&
| [if .kind == "plugin" and (.slug | type) == "string"
then "plugin:" + .slug + "/" + .key else .kind + ":" + .key end] | @tsv' \
"$MANIFEST" >"$R/manifest_ids" &&
jq -r '.plugin_marketplaces[]
| if type == "object" and (.slug | type) == "string" then .slug else "" end
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
@@ -192,6 +199,16 @@ fi
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
fi
# Records from before plugins were tracked per marketplace ("plugin:<key>")
# carry their slug: rename them so they are neither reinstalled nor orphaned.
# One without a string slug keeps its id and is dropped as unrecognised below.
if ! jq '.items |= with_entries(
if (.key | startswith("plugin:")) and (.key | contains("/") | not)
and (.value | type) == "object" and (.value.slug | type) == "string"
then .key = "plugin:" + .value.slug + "/" + (.key | ltrimstr("plugin:"))
else . end)' "$STATE" >"$OLD" 2>/dev/null; then
malformed "the marketplace state could not be read, so nothing was changed"
fi
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
while IFS="$TAB" read -r status kind key commit slug; do
@@ -265,7 +282,7 @@ done <"$R/items.tsv"
# ── Removals (non-plugin) ────────────────────────────────────────────────────
cut -f1 "$R/newstate" >"$R/new_ids"
jq -r '.items | keys[]' "$STATE" >"$R/old_ids"
jq -r '.items | keys[]' "$OLD" >"$R/old_ids"
while read -r id; do
case "$id" in plugin:*) continue ;; esac
if grep -qxF "$id" "$R/new_ids"; then continue; fi
@@ -296,7 +313,7 @@ done <"$R/old_ids"
# shellcheck disable=SC2016 # jq program, not shell
MERGE_ENTRIES='[.[] | .entries? // empty]
| reduce .[] as $e ({}; reduce ($e | to_entries[]) as $x (.; .[$x.key] += $x.value))'
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$STATE")
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$OLD")
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
HOOKS_FAILED=0
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
@@ -358,7 +375,7 @@ if [ "$HOOKS_FAILED" = 0 ]; then
fi
# ── Plugins ──────────────────────────────────────────────────────────────────
jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs"
jq -r '.plugin_marketplaces[]?' "$OLD" >"$R/old_slugs"
while read -r slug; do
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
mname="triple-c-$slug"
@@ -377,16 +394,17 @@ while read -r slug; do
key=${key#_} commit=${commit#_} pslug=${pslug#_}
[ "$pslug" = "$slug" ] || continue
id="plugin:$key"
p=$(prev_commit "$id")
sid="plugin:$slug/$key"
p=$(prev_commit "$sid")
if [ -z "$p" ]; then
claude_cmd plugin install "$key@$mname" || { fail "$id: install failed"; continue; }
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): install failed"; continue; }
report installed "$id"
elif [ "$p" != "$commit" ]; then
claude_cmd plugin uninstall "$key@$mname"
claude_cmd plugin install "$key@$mname" || { fail "$id: reinstall failed"; continue; }
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): reinstall failed"; continue; }
report updated "$id"
fi
record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
record "$sid" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
done <"$R/plugin_items"
done <"$R/new_slugs"
@@ -395,16 +413,20 @@ while read -r id; do
case "$id" in plugin:*) ;; *) continue ;; esac
if grep -qxF "$id" "$R/new_ids" || cut -f1 "$R/newstate" | grep -qxF "$id"; then continue; fi
if in_manifest "$id"; then carry_forward "$id"; continue; fi
key=${id#plugin:}
slug=$(jq -r --arg id "$id" '.items[$id].slug // ""' "$STATE")
# Name and marketplace come from the id alone ("plugin:<slug>/<key>").
rest=${id#plugin:}
case "$rest" in
*/*) slug=${rest%%/*} key=${rest#*/} ;;
*) slug="" key="" ;;
esac
if ! valid_key "$key" || ! valid_slug "$slug"; then
fail "$id: dropped an unrecognised record from the marketplace state"
continue
fi
if claude_cmd plugin uninstall "$key@triple-c-$slug"; then
report removed "$id"
report removed "plugin:$key"
else
fail "$id: uninstall failed"
fail "plugin:$key (triple-c-$slug): uninstall failed"
carry_forward "$id"
fi
done <"$R/old_ids"
@@ -426,7 +448,7 @@ if [ "$HOOKS_FAILED" = 1 ]; then
jq -s '.[0] as $new | .[1].items as $old
| ($new | with_entries(select(.key | startswith("hook:") | not)))
+ ($old | with_entries(select(.key | startswith("hook:"))))' \
"$R/items.json" "$STATE" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
"$R/items.json" "$OLD" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
fi
if jq -n --slurpfile it "$R/items.json" --rawfile sl "$R/final_slugs" \
'{ version: 1, items: $it[0], plugin_marketplaces: ($sl | split("\n") | map(select(length > 0)) | unique) }' \
@@ -884,3 +884,143 @@ fn sync_drops_state_records_without_a_kind_key_id() {
let state = read_json(&state_path);
assert_eq!(state["items"], json!({}), "bogus records dropped");
}
// ── Plugin state per marketplace (final review I1) ───────────────────────────
const SLUG_A: &str = "mp-aaaaaaaa";
const SLUG_B: &str = "mp-bbbbbbbb";
/// A payload in which each marketplace ships plugin `p` at its own commit.
fn shared_plugin_payload(env: &Env, slugs: &[(&str, &str)]) {
let mut files: Vec<(String, String)> = Vec::new();
let mut items = Vec::new();
let mut groups = Vec::new();
for (slug, commit) in slugs {
files.push((
format!("plugins/{slug}/.claude-plugin/marketplace.json"),
format!(
r#"{{"name":"triple-c-{slug}","owner":{{"name":"Triple-C"}},"plugins":[{{"name":"p","source":"./p"}}]}}"#
),
));
files.push((
format!("plugins/{slug}/p/.claude-plugin/plugin.json"),
r#"{"name":"p"}"#.to_string(),
));
items.push(json!({ "kind": "plugin", "key": "p", "marketplace": slug, "commit": commit, "slug": slug }));
groups.push(json!({ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": ["p"] }));
}
let refs: Vec<(&str, &str, bool)> = files
.iter()
.map(|(p, t)| (p.as_str(), t.as_str(), false))
.collect();
payload(
env,
&refs,
json!({ "version": 1, "items": items, "plugin_marketplaces": groups }),
);
}
fn nothing_reported(r: &SyncReport) -> bool {
r.installed.is_empty() && r.updated.is_empty() && r.removed.is_empty() && r.errors.is_empty()
}
#[test]
fn two_marketplaces_sharing_a_plugin_name_reach_a_steady_state() {
let Some(env) = env() else { return };
shared_plugin_payload(&env, &[(SLUG_A, C1), (SLUG_B, C2)]);
let r = run(&env);
assert_eq!(r.installed, vec!["plugin:p", "plugin:p"], "{r:?}");
assert!(r.errors.is_empty(), "{r:?}");
// Nothing changed: no reinstall, nothing reported.
for _ in 0..2 {
fs::remove_file(&env.log).unwrap();
shared_plugin_payload(&env, &[(SLUG_A, C1), (SLUG_B, C2)]);
let r = run(&env);
assert!(nothing_reported(&r), "{r:?}");
assert_eq!(
claude_log(&env),
vec![
format!("plugin marketplace update triple-c-{SLUG_A}"),
format!("plugin marketplace update triple-c-{SLUG_B}"),
]
);
}
// One marketplace's copy is removed: only that copy is uninstalled.
fs::remove_file(&env.log).unwrap();
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
let r = run(&env);
assert_eq!(r.removed, vec!["plugin:p"], "{r:?}");
assert!(r.installed.is_empty() && r.updated.is_empty(), "{r:?}");
assert_eq!(
claude_log(&env),
vec![
format!("plugin marketplace update triple-c-{SLUG_A}"),
format!("plugin uninstall p@triple-c-{SLUG_B}"),
format!("plugin marketplace remove triple-c-{SLUG_B}"),
]
);
// And the survivor stays put.
fs::remove_file(&env.log).unwrap();
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
let r = run(&env);
assert!(nothing_reported(&r), "{r:?}");
assert_eq!(
claude_log(&env),
vec![format!("plugin marketplace update triple-c-{SLUG_A}")]
);
}
fn write_state(env: &Env, state: Value) -> PathBuf {
let p = env.home.join(".claude/triple-c/marketplace/state.json");
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, state.to_string()).unwrap();
p
}
#[test]
fn legacy_plugin_records_are_migrated_not_reinstalled() {
let Some(env) = env() else { return };
// State as written by an earlier sync.sh: plugins keyed "plugin:<key>".
let state_path = write_state(
&env,
json!({ "version": 1, "plugin_marketplaces": [SLUG_A],
"items": { "plugin:p": { "commit": C1, "slug": SLUG_A } } }),
);
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
let r = run(&env);
assert!(nothing_reported(&r), "{r:?}");
assert_eq!(
claude_log(&env),
vec![format!("plugin marketplace update triple-c-{SLUG_A}")]
);
let items = read_json(&state_path)["items"].clone();
assert!(items.get("plugin:p").is_none(), "{items}");
assert_eq!(items[format!("plugin:{SLUG_A}/p")]["commit"], C1, "{items}");
}
#[test]
fn a_deselected_legacy_plugin_record_is_still_uninstalled() {
let Some(env) = env() else { return };
let state_path = write_state(
&env,
json!({ "version": 1, "plugin_marketplaces": [SLUG_A],
"items": { "plugin:p": { "commit": C1, "slug": SLUG_A } } }),
);
payload(&env, &[], empty_manifest());
let r = run(&env);
assert_eq!(r.removed, vec!["plugin:p"], "{r:?}");
assert_eq!(
claude_log(&env),
vec![
format!("plugin uninstall p@triple-c-{SLUG_A}"),
format!("plugin marketplace remove triple-c-{SLUG_A}"),
]
);
assert_eq!(read_json(&state_path)["items"], json!({}));
}
@@ -252,7 +252,9 @@ installed last time, including the exact hook entries it inserted).
- Plugins: marketplace name `triple-c-<slug>`; copy the generated tree to
`~/.claude/triple-c/plugins/<slug>/`; `claude plugin marketplace add` it the first time, else
`claude plugin marketplace update triple-c-<slug>`; `install` newly selected, `uninstall`
removed; drop the marketplace registration when it has no plugins left.
removed; drop the marketplace registration when it has no plugins left. Plugin state is kept
per marketplace (`plugin:<slug>/<key>`), so two marketplaces may ship a plugin of the same
name; older `plugin:<key>` records are migrated using their recorded slug.
- Emits a report: `{installed, updated, removed, skipped: [{item, reason}], errors: [...]}`.
**Failure handling.** A sync failure never fails the container start; it is logged, stored as the