Merge Task 8 (container sync script) into feat/marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # app/src-tauri/src/marketplace/mod.rs
This commit is contained in:
@@ -7,8 +7,11 @@ pub mod catalog;
|
|||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
|
pub mod sync;
|
||||||
pub mod tree;
|
pub mod tree;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod sync_script_tests;
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) mod test_support;
|
pub(crate) mod test_support;
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! Pushes a project's marketplace payload into its container and runs the
|
||||||
|
//! sync script there (spec §4).
|
||||||
|
|
||||||
|
/// Where the payload and the script are uploaded. Owned by `claude`.
|
||||||
|
pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming";
|
||||||
|
|
||||||
|
/// The sync script. Shipped with the app and uploaded on every sync, so a new
|
||||||
|
/// app version reaches existing containers without an image migration.
|
||||||
|
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Messages name paths as the user sees them ("~/.claude/..."), deliberately.
|
||||||
|
# shellcheck disable=SC2088
|
||||||
|
# Triple-C marketplace sync: applies the payload the app uploaded.
|
||||||
|
#
|
||||||
|
# A constant script, shipped inside the app and uploaded next to the payload on
|
||||||
|
# every sync. Nothing is ever interpolated into it: its only inputs are the
|
||||||
|
# files under $MARKETPLACE_INCOMING (written by the host) and $HOME. Item keys
|
||||||
|
# and slugs are re-validated here although the host validated them, and every
|
||||||
|
# destination path is derived from them rather than taken from the manifest.
|
||||||
|
#
|
||||||
|
# Progress and tool output go to stderr. stdout carries exactly one line: the
|
||||||
|
# JSON report. Exit status is 0 unless HOME is unset; per-item failures are
|
||||||
|
# reported, never fatal.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
if [ -z "${HOME:-}" ]; then
|
||||||
|
echo "triple-c-marketplace-sync: HOME is not set" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
CLAUDE_DIR="$HOME/.claude"
|
||||||
|
BASE="$CLAUDE_DIR/triple-c"
|
||||||
|
INCOMING="${MARKETPLACE_INCOMING:-$BASE/marketplace/incoming}"
|
||||||
|
LOCK="${MARKETPLACE_LOCK:-/tmp/.triple-c-claude-update.lock}"
|
||||||
|
STATE="$BASE/marketplace/state.json"
|
||||||
|
WORK="$BASE/marketplace/work"
|
||||||
|
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||||
|
TAB=$(printf '\t')
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' '{"errors":["jq is not installed in this container, so marketplace items were not applied"]}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
R=$(mktemp -d 2>/dev/null) || R=""
|
||||||
|
if [ -z "$R" ] || [ ! -d "$R" ]; then
|
||||||
|
printf '%s\n' '{"errors":["a temporary directory could not be created in the container, so marketplace items were not applied"]}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
trap 'rm -rf "$R"' EXIT
|
||||||
|
for f in installed updated removed skipped errors newstate new_slugs final_slugs \
|
||||||
|
hook_pending hook_removals plugin_items; do
|
||||||
|
: >"$R/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
report() { printf '%s\n' "$2" >>"$R/$1"; }
|
||||||
|
skip() { printf '%s\t%s\n' "$1" "$2" >>"$R/skipped"; }
|
||||||
|
fail() { printf '%s\n' "$1" >>"$R/errors"; }
|
||||||
|
record() { printf '%s\t%s\n' "$1" "$2" >>"$R/newstate"; }
|
||||||
|
|
||||||
|
emit_report() {
|
||||||
|
jq -cn \
|
||||||
|
--rawfile i "$R/installed" --rawfile u "$R/updated" --rawfile d "$R/removed" \
|
||||||
|
--rawfile s "$R/skipped" --rawfile e "$R/errors" '
|
||||||
|
def lines: split("\n") | map(select(length > 0));
|
||||||
|
{ installed: ($i | lines), updated: ($u | lines), removed: ($d | lines),
|
||||||
|
skipped: ($s | lines | map(split("\t") | { item: .[0], reason: (.[1:] | join("\t")) })),
|
||||||
|
errors: ($e | lines) }'
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_key() {
|
||||||
|
case "$1" in
|
||||||
|
'' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "${#1}" -le 64 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_slug() {
|
||||||
|
case "$1" in
|
||||||
|
'' | -* | *[!a-z0-9-]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "${#1}" -le 64 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_commit() {
|
||||||
|
case "$1" in
|
||||||
|
'' | *[!0-9a-f]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "${#1}" -eq 40 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run `claude` serialised with the entrypoint's and every session's
|
||||||
|
# `claude update`, which rewrite ~/.claude/bin under the same lock.
|
||||||
|
claude_cmd() {
|
||||||
|
if command -v flock >/dev/null 2>&1; then
|
||||||
|
flock -w 120 "$LOCK" claude "$@" </dev/null >&2
|
||||||
|
else
|
||||||
|
claude "$@" </dev/null >&2
|
||||||
|
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
|
||||||
|
# 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")"; }
|
||||||
|
# $1 = installed|updated|none for this id at this commit.
|
||||||
|
outcome_of() {
|
||||||
|
p=$(prev_commit "$1")
|
||||||
|
if [ -z "$p" ]; then
|
||||||
|
echo installed
|
||||||
|
elif [ "$p" != "$2" ]; then
|
||||||
|
echo updated
|
||||||
|
else
|
||||||
|
echo none
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
outcome() {
|
||||||
|
o=$(outcome_of "$1" "$2")
|
||||||
|
[ "$o" = none ] || report "$o" "$1"
|
||||||
|
}
|
||||||
|
# Something is in the way at a user-owned location (dangling links included).
|
||||||
|
occupied() { [ -e "$1" ] || [ -L "$1" ]; }
|
||||||
|
|
||||||
|
# The one place a destination is derived; removal never trusts a stored path.
|
||||||
|
item_path() {
|
||||||
|
case "$1" in
|
||||||
|
agent | command) printf '%s\n' "$CLAUDE_DIR/${1}s/$2.md" ;;
|
||||||
|
skill) printf '%s\n' "$CLAUDE_DIR/skills/$2" ;;
|
||||||
|
hook) printf '%s\n' "$BASE/hooks/$2" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
malformed() {
|
||||||
|
rm -rf "$WORK"
|
||||||
|
fail "$1"
|
||||||
|
emit_report
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Unpack ───────────────────────────────────────────────────────────────────
|
||||||
|
if [ ! -f "$INCOMING/payload.tar" ]; then
|
||||||
|
fail "no payload was uploaded"
|
||||||
|
emit_report
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$BASE/marketplace" "$BASE/hooks" "$BASE/plugins"
|
||||||
|
rm -rf "$WORK"
|
||||||
|
mkdir -p "$WORK"
|
||||||
|
if ! tar -xf "$INCOMING/payload.tar" -C "$WORK" >&2; then
|
||||||
|
rm -f "$INCOMING/payload.tar"
|
||||||
|
fail "the payload could not be unpacked"
|
||||||
|
emit_report
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -f "$INCOMING/payload.tar"
|
||||||
|
# The host never packs links (they make an item invalid); refuse any that
|
||||||
|
# arrive rather than copy through them.
|
||||||
|
if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then
|
||||||
|
rm -rf "$WORK"
|
||||||
|
fail "the payload contains a symbolic link, so it was not applied"
|
||||||
|
emit_report
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
MANIFEST="$WORK/manifest.json"
|
||||||
|
if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then
|
||||||
|
malformed "the payload manifest is missing or has an unsupported version"
|
||||||
|
fi
|
||||||
|
# Nothing is changed (and, above all, nothing removed) unless the manifest is
|
||||||
|
# structurally sound and every extraction below succeeds.
|
||||||
|
if ! jq -e '(.items | type) == "array" and (.plugin_marketplaces | type) == "array"' \
|
||||||
|
"$MANIFEST" >/dev/null 2>&1; then
|
||||||
|
malformed "the payload manifest is malformed, so nothing was changed"
|
||||||
|
fi
|
||||||
|
# One line per item. Fields carry a "_" prefix so an empty one cannot make
|
||||||
|
# `read` shift the rest (tab is IFS whitespace); @tsv escapes tabs/newlines.
|
||||||
|
# A malformed item becomes a "bad" line instead of aborting the extraction.
|
||||||
|
if ! {
|
||||||
|
jq -r '
|
||||||
|
.items[]
|
||||||
|
| if type == "object" and (.kind | type) == "string" and (.key | type) == "string"
|
||||||
|
and (.commit | type) == "string"
|
||||||
|
then ["ok", .kind, .key, .commit, (if (.slug | type) == "string" then .slug else "" end)]
|
||||||
|
else ["bad",
|
||||||
|
(if type == "object" then .kind | tostring else "?" end),
|
||||||
|
(if type == "object" then .key | tostring else "?" end), "", ""]
|
||||||
|
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" &&
|
||||||
|
jq -r '.plugin_marketplaces[]
|
||||||
|
| if type == "object" and (.slug | type) == "string" then .slug else "" end
|
||||||
|
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
|
||||||
|
}; then
|
||||||
|
malformed "the payload manifest could not be read, so nothing was changed"
|
||||||
|
fi
|
||||||
|
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
|
||||||
|
while IFS="$TAB" read -r status kind key commit slug; do
|
||||||
|
status=${status#_} kind=${kind#_} key=${key#_} commit=${commit#_} slug=${slug#_}
|
||||||
|
id="$kind:$key"
|
||||||
|
if [ "$status" != ok ]; then skip "$id" "malformed manifest entry"; continue; fi
|
||||||
|
if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi
|
||||||
|
if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi
|
||||||
|
case "$kind" in
|
||||||
|
plugin)
|
||||||
|
# Applied per plugin marketplace below.
|
||||||
|
printf '%s\t%s\t%s\n' "_$key" "_$commit" "_$slug" >>"$R/plugin_items"
|
||||||
|
;;
|
||||||
|
agent | command)
|
||||||
|
dir="$CLAUDE_DIR/${kind}s"
|
||||||
|
src="$WORK/${kind}s/$key.md"
|
||||||
|
dest=$(item_path "$kind" "$key")
|
||||||
|
if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||||
|
if occupied "$dest" && ! owned "$id"; then
|
||||||
|
skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! { mkdir -p "$dir" && cp "$src" "$dest.tmp.$$" && mv -f "$dest.tmp.$$" "$dest"; }; then
|
||||||
|
rm -f "$dest.tmp.$$"
|
||||||
|
fail "$id: could not write $dest"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
outcome "$id" "$commit"
|
||||||
|
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
|
||||||
|
;;
|
||||||
|
skill)
|
||||||
|
dir="$CLAUDE_DIR/skills"
|
||||||
|
src="$WORK/skills/$key"
|
||||||
|
dest=$(item_path skill "$key")
|
||||||
|
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||||
|
if occupied "$dest" && ! owned "$id"; then
|
||||||
|
skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! { mkdir -p "$dir" && rm -rf "$dest" && cp -R "$src" "$dest"; }; then
|
||||||
|
fail "$id: could not write $dest"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
outcome "$id" "$commit"
|
||||||
|
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
|
||||||
|
;;
|
||||||
|
hook)
|
||||||
|
src="$WORK/hooks/$key"
|
||||||
|
dest=$(item_path hook "$key")
|
||||||
|
entries=$(jq -c --arg k "$key" \
|
||||||
|
'first(.items[] | objects | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST")
|
||||||
|
if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then
|
||||||
|
skip "$id" "its hook settings are not an object of arrays"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||||
|
if ! { rm -rf "$dest" && cp -R "$src" "$dest"; }; then
|
||||||
|
fail "$id: could not write $dest"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Reported only once its entries are in settings.json (see below).
|
||||||
|
printf '%s\t%s\n' "$(outcome_of "$id" "$commit")" "$id" >>"$R/hook_pending"
|
||||||
|
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \
|
||||||
|
'{commit: $c, path: $p, entries: $e}')"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
skip "$id" "unknown item kind"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <"$R/items.tsv"
|
||||||
|
|
||||||
|
# ── Removals (non-plugin) ────────────────────────────────────────────────────
|
||||||
|
cut -f1 "$R/newstate" >"$R/new_ids"
|
||||||
|
jq -r '.items | keys[]' "$STATE" >"$R/old_ids"
|
||||||
|
while read -r id; do
|
||||||
|
case "$id" in plugin:*) continue ;; esac
|
||||||
|
if grep -qxF "$id" "$R/new_ids"; then continue; fi
|
||||||
|
# Still selected but failed this run: keep the old files and record.
|
||||||
|
if in_manifest "$id"; then carry_forward "$id"; continue; fi
|
||||||
|
# Only an exact "<kind>:<key>" with a known kind names a path; anything
|
||||||
|
# else in state is dropped without deleting anything.
|
||||||
|
case "$id" in
|
||||||
|
agent:* | skill:* | command:* | hook:*)
|
||||||
|
kind=${id%%:*}
|
||||||
|
key=${id#*:}
|
||||||
|
;;
|
||||||
|
*) kind="" key="" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$kind" ] || ! valid_key "$key" || ! path=$(item_path "$kind" "$key"); then
|
||||||
|
fail "$id: dropped an unrecognised record from the marketplace state"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$kind" = hook ]; then
|
||||||
|
# Removed once its entries are out of settings.json (see below).
|
||||||
|
printf '%s\n' "$id" >>"$R/hook_removals"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi
|
||||||
|
done <"$R/old_ids"
|
||||||
|
|
||||||
|
# ── Hook entries in settings.json ────────────────────────────────────────────
|
||||||
|
# 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")
|
||||||
|
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
|
||||||
|
HOOKS_FAILED=0
|
||||||
|
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
|
||||||
|
# A dotfiles symlink stays a symlink: write through to its target.
|
||||||
|
target="$SETTINGS"
|
||||||
|
if [ -L "$SETTINGS" ]; then
|
||||||
|
target=$(readlink -f "$SETTINGS" 2>/dev/null) || target=""
|
||||||
|
fi
|
||||||
|
tmp="$target.tmp.$$"
|
||||||
|
# settings.json may hold secrets and the entrypoint keeps it 0600: create
|
||||||
|
# the replacement private and keep it that way (pre-flight N11).
|
||||||
|
saved_umask=$(umask)
|
||||||
|
umask 077
|
||||||
|
if [ -z "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then
|
||||||
|
HOOKS_FAILED=1
|
||||||
|
fail "~/.claude/settings.json is not a regular file, so hook changes were not applied"
|
||||||
|
elif [ -f "$target" ] && ! jq -s '
|
||||||
|
if length == 0 then {}
|
||||||
|
elif length == 1 and (.[0] | type) == "object" then .[0]
|
||||||
|
else error("not a JSON object") end' "$target" >"$R/current.json" 2>/dev/null; then
|
||||||
|
HOOKS_FAILED=1
|
||||||
|
fail "~/.claude/settings.json is not a JSON object, so hook changes were not applied"
|
||||||
|
else
|
||||||
|
# Missing, empty and whitespace-only files all read as {}.
|
||||||
|
[ -f "$target" ] || printf '{}\n' >"$R/current.json"
|
||||||
|
if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" '
|
||||||
|
def remove_first($x):
|
||||||
|
(to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i
|
||||||
|
| if $i == null then . else del(.[$i]) end;
|
||||||
|
reduce ($old | to_entries[]) as $ev (.;
|
||||||
|
if (.hooks[$ev.key] | type) == "array"
|
||||||
|
then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g))
|
||||||
|
else . end)
|
||||||
|
| reduce ($new | to_entries[]) as $ev (.;
|
||||||
|
.hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value))
|
||||||
|
| if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end
|
||||||
|
| if .hooks == {} then del(.hooks) else . end
|
||||||
|
' "$R/current.json" >"$tmp" 2>/dev/null &&
|
||||||
|
jq -e 'type == "object"' "$tmp" >/dev/null 2>&1 &&
|
||||||
|
mv -f "$tmp" "$target"; then
|
||||||
|
chmod 600 "$target" ||
|
||||||
|
fail "~/.claude/settings.json was updated but could not be made private (chmod 600)"
|
||||||
|
else
|
||||||
|
rm -f "$tmp"
|
||||||
|
HOOKS_FAILED=1
|
||||||
|
fail "~/.claude/settings.json could not be updated, so hook changes were not applied"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
umask "$saved_umask"
|
||||||
|
fi
|
||||||
|
if [ "$HOOKS_FAILED" = 0 ]; then
|
||||||
|
while IFS="$TAB" read -r o id; do
|
||||||
|
[ "$o" = none ] || report "$o" "$id"
|
||||||
|
done <"$R/hook_pending"
|
||||||
|
while read -r id; do
|
||||||
|
key=${id#hook:}
|
||||||
|
if rm -rf "$(item_path hook "$key")"; then report removed "$id"; else fail "$id: could not remove its files"; fi
|
||||||
|
done <"$R/hook_removals"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Plugins ──────────────────────────────────────────────────────────────────
|
||||||
|
jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs"
|
||||||
|
while read -r slug; do
|
||||||
|
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
|
||||||
|
mname="triple-c-$slug"
|
||||||
|
dest="$BASE/plugins/$slug"
|
||||||
|
if ! { rm -rf "$dest" && cp -R "$WORK/plugins/$slug" "$dest"; }; then
|
||||||
|
fail "$mname: could not write $dest"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if grep -qxF "$slug" "$R/old_slugs"; then
|
||||||
|
claude_cmd plugin marketplace update "$mname" || fail "$mname: marketplace update failed"
|
||||||
|
elif ! claude_cmd plugin marketplace add "$dest"; then
|
||||||
|
claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; }
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$slug" >>"$R/final_slugs"
|
||||||
|
while IFS="$TAB" read -r key commit pslug; do
|
||||||
|
key=${key#_} commit=${commit#_} pslug=${pslug#_}
|
||||||
|
[ "$pslug" = "$slug" ] || continue
|
||||||
|
id="plugin:$key"
|
||||||
|
p=$(prev_commit "$id")
|
||||||
|
if [ -z "$p" ]; then
|
||||||
|
claude_cmd plugin install "$key@$mname" || { fail "$id: 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; }
|
||||||
|
report updated "$id"
|
||||||
|
fi
|
||||||
|
record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
|
||||||
|
done <"$R/plugin_items"
|
||||||
|
done <"$R/new_slugs"
|
||||||
|
|
||||||
|
# Plugins no longer selected.
|
||||||
|
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")
|
||||||
|
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"
|
||||||
|
else
|
||||||
|
fail "$id: uninstall failed"
|
||||||
|
carry_forward "$id"
|
||||||
|
fi
|
||||||
|
done <"$R/old_ids"
|
||||||
|
|
||||||
|
# Plugin marketplaces with nothing left in them.
|
||||||
|
cut -f2- "$R/newstate" | jq -r 'select(has("slug")) | .slug' >>"$R/final_slugs"
|
||||||
|
while read -r slug; do
|
||||||
|
if grep -qxF "$slug" "$R/final_slugs"; then continue; fi
|
||||||
|
valid_slug "$slug" || continue
|
||||||
|
claude_cmd plugin marketplace remove "triple-c-$slug" || fail "triple-c-$slug: could not be removed"
|
||||||
|
rm -rf "$BASE/plugins/$slug"
|
||||||
|
done <"$R/old_slugs"
|
||||||
|
|
||||||
|
# ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
jq -Rn '[inputs | split("\t") | { key: .[0], value: (.[1:] | join("\t") | fromjson) }] | from_entries' \
|
||||||
|
<"$R/newstate" >"$R/items.json"
|
||||||
|
if [ "$HOOKS_FAILED" = 1 ]; then
|
||||||
|
# settings.json still holds the old entries, so the old records stay true.
|
||||||
|
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"
|
||||||
|
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) }' \
|
||||||
|
>"$STATE.tmp.$$"; then
|
||||||
|
mv -f "$STATE.tmp.$$" "$STATE"
|
||||||
|
else
|
||||||
|
rm -f "$STATE.tmp.$$"
|
||||||
|
fail "the marketplace state could not be saved"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$WORK"
|
||||||
|
emit_report
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
//! Runs the real `sync.sh` against a throwaway `$HOME`, with a stub `claude`
|
||||||
|
//! on `PATH` that records its arguments. Skipped when `jq` or `tar` is missing.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::sync::SYNC_SCRIPT;
|
||||||
|
use crate::models::marketplace::SyncReport;
|
||||||
|
|
||||||
|
const C1: &str = "1111111111111111111111111111111111111111";
|
||||||
|
const C2: &str = "2222222222222222222222222222222222222222";
|
||||||
|
const SLUG: &str = "team-tools-m1aaaaaa";
|
||||||
|
|
||||||
|
fn have(tool: &str) -> bool {
|
||||||
|
Command::new(tool)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Env {
|
||||||
|
_root: tempfile::TempDir,
|
||||||
|
home: PathBuf,
|
||||||
|
incoming: PathBuf,
|
||||||
|
stub_dir: PathBuf,
|
||||||
|
log: PathBuf,
|
||||||
|
script: PathBuf,
|
||||||
|
lock: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env() -> Option<Env> {
|
||||||
|
if !have("jq") || !have("tar") {
|
||||||
|
eprintln!("skipping: jq or tar is not installed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let home = root.path().join("home");
|
||||||
|
let incoming = root.path().join("incoming");
|
||||||
|
let stub_dir = root.path().join("bin");
|
||||||
|
for d in [&home, &incoming, &stub_dir] {
|
||||||
|
fs::create_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
let log = root.path().join("claude.log");
|
||||||
|
let stub = stub_dir.join("claude");
|
||||||
|
fs::write(
|
||||||
|
&stub,
|
||||||
|
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CLAUDE_LOG\"\nexit 0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
let script = root.path().join("sync.sh");
|
||||||
|
fs::write(&script, SYNC_SCRIPT).unwrap();
|
||||||
|
let lock = root.path().join("lock");
|
||||||
|
Some(Env {
|
||||||
|
home,
|
||||||
|
incoming,
|
||||||
|
stub_dir,
|
||||||
|
log,
|
||||||
|
script,
|
||||||
|
lock,
|
||||||
|
_root: root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `payload.tar` into the incoming dir: `files` plus `manifest.json`.
|
||||||
|
fn payload(env: &Env, files: &[(&str, &str, bool)], manifest: Value) {
|
||||||
|
let mut b = tar::Builder::new(Vec::new());
|
||||||
|
let mut add = |path: &str, data: &[u8], exec: bool| {
|
||||||
|
let mut h = tar::Header::new_gnu();
|
||||||
|
h.set_size(data.len() as u64);
|
||||||
|
h.set_mode(if exec { 0o755 } else { 0o644 });
|
||||||
|
h.set_entry_type(tar::EntryType::Regular);
|
||||||
|
b.append_data(&mut h, path, data).unwrap();
|
||||||
|
};
|
||||||
|
for (path, text, exec) in files {
|
||||||
|
add(path, text.as_bytes(), *exec);
|
||||||
|
}
|
||||||
|
add("manifest.json", manifest.to_string().as_bytes(), false);
|
||||||
|
fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(env: &Env) -> SyncReport {
|
||||||
|
run_with(env, "sh")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the script under a specific shell (`sh` is dash on Ubuntu).
|
||||||
|
fn run_with(env: &Env, shell: &str) -> SyncReport {
|
||||||
|
run_full(env, shell, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the script with extra environment variables.
|
||||||
|
fn run_full(env: &Env, shell: &str, extra: &[(&str, &str)]) -> SyncReport {
|
||||||
|
let out = Command::new(shell)
|
||||||
|
.arg(&env.script)
|
||||||
|
.env_clear()
|
||||||
|
.envs(extra.iter().copied())
|
||||||
|
.env("HOME", &env.home)
|
||||||
|
.env(
|
||||||
|
"PATH",
|
||||||
|
format!("{}:/usr/local/bin:/usr/bin:/bin", env.stub_dir.display()),
|
||||||
|
)
|
||||||
|
.env("MARKETPLACE_INCOMING", &env.incoming)
|
||||||
|
.env("MARKETPLACE_LOCK", &env.lock)
|
||||||
|
.env("CLAUDE_LOG", &env.log)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"script failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let last = stdout
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.expect("a report line");
|
||||||
|
serde_json::from_str(last).unwrap_or_else(|e| panic!("bad report {last:?}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_log(env: &Env) -> Vec<String> {
|
||||||
|
fs::read_to_string(&env.log)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json(p: &Path) -> Value {
|
||||||
|
serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_settings() -> Value {
|
||||||
|
json!({ "Stop": [{ "hooks": [{ "type": "command",
|
||||||
|
"command": "/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh" }] }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_kinds(commit: &str) -> (Vec<(&'static str, &'static str, bool)>, Value) {
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
("agents/code-reviewer.md", "agent body\n", false),
|
||||||
|
("skills/example-skill/SKILL.md", "skill body\n", false),
|
||||||
|
("commands/example-command.md", "command body\n", false),
|
||||||
|
("hooks/notify-on-stop/hook.json", "{}", false),
|
||||||
|
(
|
||||||
|
"hooks/notify-on-stop/notify.sh",
|
||||||
|
"#!/bin/sh\necho hi\n",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||||||
|
{ "kind": "agent", "key": "code-reviewer", "marketplace": "m1", "commit": commit, "file": "agents/code-reviewer.md" },
|
||||||
|
{ "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": commit, "dir": "skills/example-skill" },
|
||||||
|
{ "kind": "command", "key": "example-command", "marketplace": "m1", "commit": commit, "file": "commands/example-command.md" },
|
||||||
|
{ "kind": "hook", "key": "notify-on-stop", "marketplace": "m1", "commit": commit, "dir": "hooks/notify-on-stop", "settings": hook_settings() }
|
||||||
|
]}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_manifest() -> Value {
|
||||||
|
json!({ "version": 1, "items": [], "plugin_marketplaces": [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted(mut v: Vec<String>) -> Vec<String> {
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_installs_all_kinds() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(r.errors, Vec::<String>::new());
|
||||||
|
assert_eq!(
|
||||||
|
sorted(r.installed),
|
||||||
|
vec![
|
||||||
|
"agent:code-reviewer",
|
||||||
|
"command:example-command",
|
||||||
|
"hook:notify-on-stop",
|
||||||
|
"skill:example-skill"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(claude.join("agents/code-reviewer.md")).unwrap(),
|
||||||
|
"agent body\n"
|
||||||
|
);
|
||||||
|
assert!(claude.join("skills/example-skill/SKILL.md").is_file());
|
||||||
|
assert!(claude.join("commands/example-command.md").is_file());
|
||||||
|
let script = claude.join("triple-c/hooks/notify-on-stop/notify.sh");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
assert_ne!(
|
||||||
|
fs::metadata(&script).unwrap().permissions().mode() & 0o111,
|
||||||
|
0,
|
||||||
|
"hook script must stay executable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
read_json(&claude.join("settings.json"))["hooks"],
|
||||||
|
hook_settings()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!env.incoming.join("payload.tar").exists(),
|
||||||
|
"payload is consumed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second identical run is a no-op in the report.
|
||||||
|
payload(&env, &all_kinds(C1).0, all_kinds(C1).1);
|
||||||
|
let again = run(&env);
|
||||||
|
assert!(
|
||||||
|
again.installed.is_empty() && again.updated.is_empty() && again.removed.is_empty(),
|
||||||
|
"{again:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_json(&claude.join("settings.json"))["hooks"]["Stop"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_updates_report_changed_commits() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
run(&env);
|
||||||
|
let (files, manifest) = all_kinds(C2);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(r.updated.len(), 4, "{r:?}");
|
||||||
|
assert!(r.installed.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_removes_deselected() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
run(&env);
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sorted(r.removed),
|
||||||
|
vec![
|
||||||
|
"agent:code-reviewer",
|
||||||
|
"command:example-command",
|
||||||
|
"hook:notify-on-stop",
|
||||||
|
"skill:example-skill"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
assert!(!claude.join("agents/code-reviewer.md").exists());
|
||||||
|
assert!(!claude.join("skills/example-skill").exists());
|
||||||
|
assert!(!claude.join("commands/example-command.md").exists());
|
||||||
|
assert!(!claude.join("triple-c/hooks/notify-on-stop").exists());
|
||||||
|
assert_eq!(read_json(&claude.join("settings.json")).get("hooks"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_skips_user_owned_agent() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let mine = env.home.join(".claude/agents/code-reviewer.md");
|
||||||
|
fs::create_dir_all(mine.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&mine, "mine\n").unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(r.skipped.len(), 1, "{r:?}");
|
||||||
|
assert_eq!(r.skipped[0].item, "agent:code-reviewer");
|
||||||
|
assert!(
|
||||||
|
r.skipped[0]
|
||||||
|
.reason
|
||||||
|
.contains("was not installed by Triple-C"),
|
||||||
|
"{}",
|
||||||
|
r.skipped[0].reason
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
|
||||||
|
|
||||||
|
// Deselecting everything must not delete the user's own file either.
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
assert!(!r.removed.contains(&"agent:code-reviewer".to_string()));
|
||||||
|
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_preserves_user_hooks() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let settings_path = env.home.join(".claude/settings.json");
|
||||||
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||||
|
let original = json!({
|
||||||
|
"model": "opus",
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [{ "hooks": [{ "type": "command", "command": "echo mine" }] }],
|
||||||
|
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&original).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
run(&env);
|
||||||
|
let merged = read_json(&settings_path);
|
||||||
|
assert_eq!(merged["model"], "opus");
|
||||||
|
assert_eq!(
|
||||||
|
merged["hooks"]["PreToolUse"],
|
||||||
|
original["hooks"]["PreToolUse"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged["hooks"]["Stop"][0], original["hooks"]["Stop"][0],
|
||||||
|
"user hook stays first"
|
||||||
|
);
|
||||||
|
assert_eq!(merged["hooks"]["Stop"][1], hook_settings()["Stop"][0]);
|
||||||
|
|
||||||
|
// An update with a changed hook entry replaces only ours.
|
||||||
|
let (files, mut manifest) = all_kinds(C2);
|
||||||
|
manifest["items"][3]["settings"]["Stop"][0]["hooks"][0]["timeout"] = json!(5);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
run(&env);
|
||||||
|
let updated = read_json(&settings_path);
|
||||||
|
assert_eq!(updated["hooks"]["Stop"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(updated["hooks"]["Stop"][0], original["hooks"]["Stop"][0]);
|
||||||
|
assert_eq!(updated["hooks"]["Stop"][1]["hooks"][0]["timeout"], 5);
|
||||||
|
|
||||||
|
// Uninstalling everything restores the user's settings exactly.
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
run(&env);
|
||||||
|
assert_eq!(read_json(&settings_path), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_plugin_calls() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let files = [
|
||||||
|
(
|
||||||
|
"plugins/team-tools-m1aaaaaa/.claude-plugin/marketplace.json",
|
||||||
|
r#"{"name":"triple-c-team-tools-m1aaaaaa","owner":{"name":"Triple-C"},"plugins":[{"name":"example-plugin","source":"./example-plugin"}]}"#,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plugins/team-tools-m1aaaaaa/example-plugin/.claude-plugin/plugin.json",
|
||||||
|
r#"{"name":"example-plugin"}"#,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let manifest = |commit: &str| {
|
||||||
|
json!({ "version": 1,
|
||||||
|
"items": [{ "kind": "plugin", "key": "example-plugin", "marketplace": "m1", "commit": commit, "slug": SLUG }],
|
||||||
|
"plugin_marketplaces": [{ "slug": SLUG, "dir": format!("plugins/{SLUG}"), "plugins": ["example-plugin"] }] })
|
||||||
|
};
|
||||||
|
let tree = env.home.join(".claude/triple-c/plugins").join(SLUG);
|
||||||
|
|
||||||
|
payload(&env, &files, manifest(C1));
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.installed, vec!["plugin:example-plugin"]);
|
||||||
|
assert_eq!(
|
||||||
|
claude_log(&env),
|
||||||
|
vec![
|
||||||
|
format!("plugin marketplace add {}", tree.display()),
|
||||||
|
format!("plugin install example-plugin@triple-c-{SLUG}"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(tree.join(".claude-plugin/marketplace.json").is_file());
|
||||||
|
|
||||||
|
// Same commit again: catalog refreshed, nothing reinstalled.
|
||||||
|
fs::remove_file(&env.log).unwrap();
|
||||||
|
payload(&env, &files, manifest(C1));
|
||||||
|
run(&env);
|
||||||
|
assert_eq!(
|
||||||
|
claude_log(&env),
|
||||||
|
vec![format!("plugin marketplace update triple-c-{SLUG}")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// New commit: uninstall + install.
|
||||||
|
fs::remove_file(&env.log).unwrap();
|
||||||
|
payload(&env, &files, manifest(C2));
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.updated, vec!["plugin:example-plugin"]);
|
||||||
|
assert_eq!(
|
||||||
|
claude_log(&env),
|
||||||
|
vec![
|
||||||
|
format!("plugin marketplace update triple-c-{SLUG}"),
|
||||||
|
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
|
||||||
|
format!("plugin install example-plugin@triple-c-{SLUG}"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deselected: uninstall, drop the registration and the tree.
|
||||||
|
fs::remove_file(&env.log).unwrap();
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.removed, vec!["plugin:example-plugin"]);
|
||||||
|
assert_eq!(
|
||||||
|
claude_log(&env),
|
||||||
|
vec![
|
||||||
|
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
|
||||||
|
format!("plugin marketplace remove triple-c-{SLUG}"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!tree.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_rejects_bad_keys() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
payload(
|
||||||
|
&env,
|
||||||
|
&[("agents/x.md", "x", false)],
|
||||||
|
json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||||||
|
{ "kind": "agent", "key": "../../evil", "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent", "key": "-rf", "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent", "key": "ok", "marketplace": "m1", "commit": "not-a-sha" }
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.skipped.len(), 3, "{r:?}");
|
||||||
|
assert!(r.installed.is_empty());
|
||||||
|
assert!(!env.home.join("evil.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_payload_is_reported_not_fatal() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.errors, vec!["no payload was uploaded"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_keeps_settings_json_private() {
|
||||||
|
// Pre-flight N11: the entrypoint keeps settings.json at 0600; a hook
|
||||||
|
// merge must not leave it world-readable.
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let settings_path = env.home.join(".claude/settings.json");
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
run(&env);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode(&settings_path), 0o600, "created by the merge");
|
||||||
|
|
||||||
|
fs::set_permissions(&settings_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
run(&env);
|
||||||
|
assert_eq!(
|
||||||
|
mode(&settings_path),
|
||||||
|
0o600,
|
||||||
|
"rewritten by the uninstall merge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_runs_under_bash_and_dash() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
for shell in ["bash", "dash"] {
|
||||||
|
if !Path::new("/bin").join(shell).exists() && !have(shell) {
|
||||||
|
eprintln!("skipping {shell}: not installed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
let r = run_with(&env, shell);
|
||||||
|
assert_eq!(r.errors, Vec::<String>::new(), "{shell}");
|
||||||
|
assert_eq!(r.installed.len(), 4, "{shell}: {r:?}");
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run_with(&env, shell);
|
||||||
|
assert_eq!(r.removed.len(), 4, "{shell}: {r:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_never_interpolates_hostile_keys() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let canary = env.home.join("pwned");
|
||||||
|
let evil = format!("a$(touch {})", canary.display());
|
||||||
|
let evil_tab = "a\tb";
|
||||||
|
let evil_nl = "ok\nagent\tgood";
|
||||||
|
let evil_slug = format!("s;touch {}", canary.display());
|
||||||
|
payload(
|
||||||
|
&env,
|
||||||
|
&[("agents/a.md", "x", false)],
|
||||||
|
json!({ "version": 1,
|
||||||
|
"plugin_marketplaces": [{ "slug": evil_slug, "dir": "plugins/x", "plugins": [] }],
|
||||||
|
"items": [
|
||||||
|
{ "kind": "agent", "key": evil, "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent", "key": evil_tab, "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent", "key": evil_nl, "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent", "key": "a".repeat(65), "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "agent$(id)", "key": "a", "marketplace": "m1", "commit": C1 },
|
||||||
|
{ "kind": "plugin", "key": "p", "marketplace": "m1", "commit": C1, "slug": evil_slug }
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let r = run(&env);
|
||||||
|
assert!(r.installed.is_empty(), "{r:?}");
|
||||||
|
assert_eq!(r.skipped.len(), 5, "{r:?}");
|
||||||
|
assert!(!canary.exists(), "a manifest value was executed");
|
||||||
|
assert!(claude_log(&env).is_empty(), "{:?}", claude_log(&env));
|
||||||
|
assert!(!env.home.join(".claude/agents").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_rejects_a_payload_with_symlinks() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let outside = env.home.join("outside");
|
||||||
|
fs::create_dir_all(&outside).unwrap();
|
||||||
|
let mut b = tar::Builder::new(Vec::new());
|
||||||
|
let mut h = tar::Header::new_gnu();
|
||||||
|
h.set_entry_type(tar::EntryType::Symlink);
|
||||||
|
h.set_size(0);
|
||||||
|
h.set_mode(0o777);
|
||||||
|
b.append_link(&mut h, "skills/example-skill", &outside)
|
||||||
|
.unwrap();
|
||||||
|
let manifest = json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||||||
|
{ "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": C1, "dir": "skills/example-skill" }
|
||||||
|
]})
|
||||||
|
.to_string();
|
||||||
|
let mut h = tar::Header::new_gnu();
|
||||||
|
h.set_size(manifest.len() as u64);
|
||||||
|
h.set_mode(0o644);
|
||||||
|
b.append_data(&mut h, "manifest.json", manifest.as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert!(r.installed.is_empty(), "{r:?}");
|
||||||
|
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||||||
|
assert!(r.errors[0].contains("symbolic link"), "{r:?}");
|
||||||
|
assert!(!env.home.join(".claude/skills/example-skill").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_skips_user_owned_skill_and_command() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
let skill = claude.join("skills/example-skill/SKILL.md");
|
||||||
|
let command = claude.join("commands/example-command.md");
|
||||||
|
for p in [&skill, &command] {
|
||||||
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||||
|
fs::write(p, "mine\n").unwrap();
|
||||||
|
}
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
let skipped = sorted(r.skipped.iter().map(|s| s.item.clone()).collect());
|
||||||
|
assert_eq!(
|
||||||
|
skipped,
|
||||||
|
vec!["command:example-command", "skill:example-skill"],
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sorted(r.installed),
|
||||||
|
vec!["agent:code-reviewer", "hook:notify-on-stop"]
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n");
|
||||||
|
assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n");
|
||||||
|
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(
|
||||||
|
sorted(r.removed),
|
||||||
|
vec!["agent:code-reviewer", "hook:notify-on-stop"]
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n");
|
||||||
|
assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review fix round 1 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn install_all(env: &Env) {
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(env, &files, manifest);
|
||||||
|
let r = run(env);
|
||||||
|
assert_eq!(r.installed.len(), 4, "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_all_installed(env: &Env) {
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
assert!(claude.join("agents/code-reviewer.md").is_file());
|
||||||
|
assert!(claude.join("skills/example-skill/SKILL.md").is_file());
|
||||||
|
assert!(claude.join("commands/example-command.md").is_file());
|
||||||
|
assert!(claude
|
||||||
|
.join("triple-c/hooks/notify-on-stop/notify.sh")
|
||||||
|
.is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_malformed_item_neither_aborts_nor_removes() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
install_all(&env);
|
||||||
|
|
||||||
|
// Malformed entries first, then the valid ones; the still-selected agent
|
||||||
|
// has a non-string commit.
|
||||||
|
let (files, mut manifest) = all_kinds(C1);
|
||||||
|
manifest["items"][0]["commit"] = json!(7);
|
||||||
|
let items = manifest["items"].as_array().unwrap().clone();
|
||||||
|
let mut all = vec![
|
||||||
|
json!({ "kind": "agent", "key": { "x": 1 }, "marketplace": "m1", "commit": C1 }),
|
||||||
|
json!(5),
|
||||||
|
json!({ "kind": ["agent"], "key": "k", "marketplace": "m1", "commit": C1 }),
|
||||||
|
json!({ "kind": "plugin", "key": { "y": 1 }, "marketplace": "m1", "commit": C1, "slug": SLUG }),
|
||||||
|
];
|
||||||
|
all.extend(items);
|
||||||
|
manifest["items"] = Value::Array(all);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert!(r.removed.is_empty(), "{r:?}");
|
||||||
|
assert!(r.errors.is_empty(), "{r:?}");
|
||||||
|
assert_all_installed(&env);
|
||||||
|
let skipped = r
|
||||||
|
.skipped
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.item.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(skipped.contains(&"agent:code-reviewer"), "{r:?}");
|
||||||
|
assert_eq!(r.skipped.len(), 5, "{r:?}");
|
||||||
|
|
||||||
|
// The carried-forward record is still owned: deselecting removes it.
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.removed.len(), 4, "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_structurally_bad_manifest_removes_nothing() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
install_all(&env);
|
||||||
|
for bad in [
|
||||||
|
json!({ "version": 1 }),
|
||||||
|
json!({ "version": 1, "items": {}, "plugin_marketplaces": [] }),
|
||||||
|
json!({ "version": 1, "items": [], "plugin_marketplaces": "x" }),
|
||||||
|
] {
|
||||||
|
payload(&env, &[], bad.clone());
|
||||||
|
let r = run(&env);
|
||||||
|
assert!(r.removed.is_empty(), "{bad}: {r:?}");
|
||||||
|
assert_eq!(r.errors.len(), 1, "{bad}: {r:?}");
|
||||||
|
assert_all_installed(&env);
|
||||||
|
}
|
||||||
|
// State survived: a real deselection still removes everything.
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
assert_eq!(run(&env).removed.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_treats_blank_settings_as_empty() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let settings_path = env.home.join(".claude/settings.json");
|
||||||
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&settings_path, " \n\t\n").unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert!(r.errors.is_empty(), "{r:?}");
|
||||||
|
assert!(
|
||||||
|
r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(read_json(&settings_path)["hooks"], hook_settings());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_does_not_report_hooks_it_could_not_wire() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let settings_path = env.home.join(".claude/settings.json");
|
||||||
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||||
|
for bad in ["[1]", "{\"a\":", "{} {}"] {
|
||||||
|
fs::write(&settings_path, bad).unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
let r = run(&env);
|
||||||
|
assert!(
|
||||||
|
!r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||||
|
"{bad}: {r:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(r.errors.len(), 1, "{bad}: {r:?}");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&settings_path).unwrap(),
|
||||||
|
bad,
|
||||||
|
"left untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Once settings.json is fixed the hook is installed for real.
|
||||||
|
fs::write(&settings_path, "{}").unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
let r = run(&env);
|
||||||
|
assert_eq!(r.installed, vec!["hook:notify-on-stop"], "{r:?}");
|
||||||
|
assert_eq!(read_json(&settings_path)["hooks"], hook_settings());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn sync_skips_dangling_user_symlinks() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
let agent = claude.join("agents/code-reviewer.md");
|
||||||
|
let skill = claude.join("skills/example-skill");
|
||||||
|
for p in [&agent, &skill] {
|
||||||
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||||
|
symlink("/nonexistent/dotfiles/target", p).unwrap();
|
||||||
|
}
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
let skipped = sorted(r.skipped.iter().map(|s| s.item.clone()).collect());
|
||||||
|
assert_eq!(
|
||||||
|
skipped,
|
||||||
|
vec!["agent:code-reviewer", "skill:example-skill"],
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
|
for p in [&agent, &skill] {
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(p).unwrap(),
|
||||||
|
Path::new("/nonexistent/dotfiles/target")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_removal_never_uses_paths_from_state() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
install_all(&env);
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
let keep = claude.join("keep.txt");
|
||||||
|
fs::write(&keep, "keep").unwrap();
|
||||||
|
let state_path = claude.join("triple-c/marketplace/state.json");
|
||||||
|
let mut state = read_json(&state_path);
|
||||||
|
state["items"]["agent:code-reviewer"]["path"] = json!(format!("{}/", claude.display()));
|
||||||
|
state["items"]["skill:example-skill"]["path"] =
|
||||||
|
json!(format!("{}/.claude", env.home.display()));
|
||||||
|
state["items"]["command:example-command"]["path"] = json!(keep.display().to_string());
|
||||||
|
fs::write(&state_path, state.to_string()).unwrap();
|
||||||
|
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(r.removed.len(), 4, "{r:?}");
|
||||||
|
assert_eq!(fs::read_to_string(&keep).unwrap(), "keep");
|
||||||
|
assert!(!claude.join("agents/code-reviewer.md").exists());
|
||||||
|
assert!(!claude.join("skills/example-skill").exists());
|
||||||
|
assert!(!claude.join("commands/example-command.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn sync_writes_through_a_symlinked_settings_json() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let dotfiles = env.home.join("dotfiles/settings.json");
|
||||||
|
fs::create_dir_all(dotfiles.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&dotfiles, r#"{"model":"opus"}"#).unwrap();
|
||||||
|
let settings_path = env.home.join(".claude/settings.json");
|
||||||
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||||
|
symlink(&dotfiles, &settings_path).unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert!(r.errors.is_empty(), "{r:?}");
|
||||||
|
assert!(
|
||||||
|
fs::symlink_metadata(&settings_path)
|
||||||
|
.unwrap()
|
||||||
|
.file_type()
|
||||||
|
.is_symlink(),
|
||||||
|
"link kept"
|
||||||
|
);
|
||||||
|
let merged = read_json(&dotfiles);
|
||||||
|
assert_eq!(merged["model"], "opus");
|
||||||
|
assert_eq!(merged["hooks"], hook_settings());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_reports_when_mktemp_fails() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
let r = run_full(&env, "sh", &[("TMPDIR", "/nonexistent/triple-c-tmp")]);
|
||||||
|
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn sync_settings_move_failure_is_reported_and_not_recorded() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
// An `mv` that refuses to replace settings.json and delegates otherwise.
|
||||||
|
let mv = env.stub_dir.join("mv");
|
||||||
|
fs::write(
|
||||||
|
&mv,
|
||||||
|
"#!/bin/sh\nfor a; do last=$a; done\ncase \"$last\" in */settings.json) exit 1 ;; esac\nexec /bin/mv \"$@\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::set_permissions(&mv, fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
let (files, manifest) = all_kinds(C1);
|
||||||
|
payload(&env, &files, manifest);
|
||||||
|
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||||||
|
assert!(
|
||||||
|
!r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
assert!(!claude.join("settings.json").exists());
|
||||||
|
let leftovers: Vec<_> = fs::read_dir(&claude)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
|
||||||
|
.collect();
|
||||||
|
assert!(leftovers.is_empty(), "temp file cleaned up");
|
||||||
|
let state = read_json(&claude.join("triple-c/marketplace/state.json"));
|
||||||
|
assert!(
|
||||||
|
state["items"].get("hook:notify-on-stop").is_none(),
|
||||||
|
"{state}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_drops_state_records_without_a_kind_key_id() {
|
||||||
|
let Some(env) = env() else { return };
|
||||||
|
install_all(&env);
|
||||||
|
let claude = env.home.join(".claude");
|
||||||
|
let mine = [
|
||||||
|
claude.join("skills/skill/SKILL.md"),
|
||||||
|
claude.join("agents/agent.md"),
|
||||||
|
];
|
||||||
|
for p in &mine {
|
||||||
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||||
|
fs::write(p, "mine\n").unwrap();
|
||||||
|
}
|
||||||
|
let state_path = claude.join("triple-c/marketplace/state.json");
|
||||||
|
let mut state = read_json(&state_path);
|
||||||
|
for bogus in ["skill", "agent", "widget:x", "agent:a:b", "plugin:a:b"] {
|
||||||
|
state["items"][bogus] = json!({ "commit": C1, "path": "/" });
|
||||||
|
}
|
||||||
|
fs::write(&state_path, state.to_string()).unwrap();
|
||||||
|
|
||||||
|
payload(&env, &[], empty_manifest());
|
||||||
|
let r = run(&env);
|
||||||
|
|
||||||
|
assert_eq!(sorted(r.removed.clone()).len(), 4, "{r:?}");
|
||||||
|
assert_eq!(r.errors.len(), 5, "{r:?}");
|
||||||
|
assert!(claude_log(&env).is_empty(), "{:?}", claude_log(&env));
|
||||||
|
for p in &mine {
|
||||||
|
assert_eq!(fs::read_to_string(p).unwrap(), "mine\n", "{}", p.display());
|
||||||
|
}
|
||||||
|
let state = read_json(&state_path);
|
||||||
|
assert_eq!(state["items"], json!({}), "bogus records dropped");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user