feat(skills): refresh marketplace release workflows
This commit is contained in:
+126
-50
@@ -25,6 +25,7 @@ CATEGORY_FILTER=""
|
||||
FORCE=false
|
||||
CLEANUP=false
|
||||
LIST_ONLY=false
|
||||
INSTALL_ACTION=false
|
||||
|
||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'
|
||||
@@ -72,14 +73,28 @@ except: pass
|
||||
fi
|
||||
}
|
||||
|
||||
state_digest() {
|
||||
# state_digest <install_name> -> prints installed content digest or empty string
|
||||
local name="$1"
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
python3 -c "
|
||||
import json
|
||||
try:
|
||||
d=json.load(open('$STATE_FILE'))
|
||||
print(d.get('$name',{}).get('content_digest',''))
|
||||
except: pass
|
||||
" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
state_set() {
|
||||
# state_set <install_name> <version> <install_type>
|
||||
local name="$1" ver="$2" itype="$3"
|
||||
# state_set <install_name> <version> <install_type> <content_digest>
|
||||
local name="$1" ver="$2" itype="$3" digest="$4"
|
||||
python3 -c "
|
||||
import json,os
|
||||
f='$STATE_FILE'
|
||||
d=json.load(open(f)) if os.path.exists(f) else {}
|
||||
d['$name']={'version':'$ver','install_type':'$itype'}
|
||||
d['$name']={'version':'$ver','install_type':'$itype','content_digest':'$digest'}
|
||||
json.dump(d,open(f,'w'),indent=2)
|
||||
" 2>/dev/null
|
||||
}
|
||||
@@ -116,6 +131,64 @@ read_field() {
|
||||
python3 -c "import json,sys; d=json.load(open('$1')); print(d.get('$2',''))" 2>/dev/null || true
|
||||
}
|
||||
|
||||
content_digest() {
|
||||
# Stable digest for one command file or a complete skill directory.
|
||||
python3 - "$1" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
target = pathlib.Path(sys.argv[1])
|
||||
if not target.exists():
|
||||
print("")
|
||||
raise SystemExit
|
||||
|
||||
digest = hashlib.sha256()
|
||||
files = [target] if target.is_file() else sorted(
|
||||
path for path in target.rglob("*") if path.is_file() or path.is_symlink()
|
||||
)
|
||||
for path in files:
|
||||
relative = path.name if target.is_file() else path.relative_to(target).as_posix()
|
||||
digest.update(relative.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
if path.is_symlink():
|
||||
digest.update(b"link\0")
|
||||
digest.update(os.readlink(path).encode("utf-8"))
|
||||
else:
|
||||
digest.update(path.read_bytes())
|
||||
digest.update(b"\0")
|
||||
print(digest.hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
is_compatible_subset() {
|
||||
# True when every file in an existing legacy target also exists unchanged in
|
||||
# the repository source. This safely upgrades old SKILL.md-only installs.
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
source = pathlib.Path(sys.argv[1])
|
||||
target = pathlib.Path(sys.argv[2])
|
||||
if not source.is_dir() or not target.is_dir():
|
||||
raise SystemExit(1)
|
||||
|
||||
for target_path in target.rglob("*"):
|
||||
if target_path.is_dir():
|
||||
continue
|
||||
source_path = source / target_path.relative_to(target)
|
||||
if not source_path.is_file() or target_path.is_symlink() != source_path.is_symlink():
|
||||
raise SystemExit(1)
|
||||
if target_path.is_symlink():
|
||||
if target_path.readlink() != source_path.readlink():
|
||||
raise SystemExit(1)
|
||||
elif target_path.read_bytes() != source_path.read_bytes():
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
}
|
||||
|
||||
# Resolve the actual source directory to rsync from.
|
||||
# If skills/ has SKILL.md at the top level, use it directly.
|
||||
# If skills/ has a single subdirectory (e.g. skills/dev-test/SKILL.md), use that subdirectory.
|
||||
@@ -135,33 +208,9 @@ resolve_skills_src() {
|
||||
echo "$skills_dir"
|
||||
}
|
||||
|
||||
# ── Conflict detection (has local been modified since we installed it?) ────────
|
||||
has_local_modification() {
|
||||
# Returns 0 (true) if local target differs from repo source, 1 if identical or new
|
||||
local install_name="$1" install_type="$2" plugin_skills_dir="$3"
|
||||
|
||||
if [[ "$install_type" == "command" ]]; then
|
||||
local src="$plugin_skills_dir/SKILL.md"
|
||||
local dst="$COMMANDS_DIR/${install_name}.md"
|
||||
[[ -f "$dst" ]] && ! diff -q "$src" "$dst" &>/dev/null && return 0
|
||||
else
|
||||
local dst_dir="$SKILLS_DIR/$install_name"
|
||||
if [[ -d "$dst_dir" ]]; then
|
||||
# Compare each file from source
|
||||
while IFS= read -r -d '' src_file; do
|
||||
local rel="${src_file#$plugin_skills_dir/}"
|
||||
local dst_file="$dst_dir/$rel"
|
||||
if [[ -f "$dst_file" ]] && ! diff -q "$src_file" "$dst_file" &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
done < <(find "$plugin_skills_dir" -type f -print0)
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Install a single plugin ────────────────────────────────────────────────────
|
||||
install_plugin() {
|
||||
INSTALL_ACTION=false
|
||||
local json_path="$1"
|
||||
local plugin_dir
|
||||
plugin_dir="$(dirname "$(dirname "$json_path")")" # strip /.claude-plugin/plugin.json
|
||||
@@ -195,27 +244,54 @@ install_plugin() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Check current installed version
|
||||
local current_version
|
||||
current_version="$(state_get "$install_name")"
|
||||
# Resolve actual source (handles plugins where content sits one level deeper,
|
||||
# e.g. skills/dev-test/SKILL.md instead of skills/SKILL.md).
|
||||
local src_dir
|
||||
src_dir="$(resolve_skills_src "$skills_dir")"
|
||||
|
||||
# Skip if up-to-date (same version) and no force
|
||||
if [[ "$current_version" == "$version" && "$FORCE" == false ]]; then
|
||||
local source_path target_path
|
||||
if [[ "$install_type" == "command" ]]; then
|
||||
source_path="$src_dir/SKILL.md"
|
||||
target_path="$COMMANDS_DIR/${install_name}.md"
|
||||
else
|
||||
source_path="$src_dir"
|
||||
target_path="$SKILLS_DIR/$install_name"
|
||||
fi
|
||||
|
||||
if [[ ! -e "$source_path" ]]; then
|
||||
warn "$install_name: install source not found, skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
# Conflict detection: warn if local files were modified
|
||||
if [[ -n "$current_version" && "$FORCE" == false ]]; then
|
||||
if has_local_modification "$install_name" "$install_type" "$skills_dir"; then
|
||||
warn "$install_name: local files were modified — skipping (use --force to overwrite)"
|
||||
return
|
||||
# A recorded content digest distinguishes repository updates from user edits.
|
||||
# Legacy state is adopted automatically only when the target is missing or
|
||||
# already identical to the repository source.
|
||||
local current_version recorded_digest source_digest target_digest
|
||||
current_version="$(state_get "$install_name")"
|
||||
recorded_digest="$(state_digest "$install_name")"
|
||||
source_digest="$(content_digest "$source_path")"
|
||||
target_digest="$(content_digest "$target_path")"
|
||||
|
||||
if [[ "$FORCE" == false && -n "$target_digest" && "$target_digest" == "$source_digest" ]]; then
|
||||
if [[ "$DRY_RUN" == false && ( "$current_version" != "$version" || "$recorded_digest" != "$source_digest" ) ]]; then
|
||||
state_set "$install_name" "$version" "$install_type" "$source_digest"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local legacy_subset=false
|
||||
if [[ "$install_type" == "skill" && -z "$recorded_digest" && -n "$target_digest" ]]; then
|
||||
if is_compatible_subset "$source_path" "$target_path"; then
|
||||
legacy_subset=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve actual source (handles plugins where content sits one level deeper,
|
||||
# e.g. skills/dev-test/SKILL.md instead of skills/SKILL.md)
|
||||
local src_dir
|
||||
src_dir="$(resolve_skills_src "$skills_dir")"
|
||||
if [[ "$FORCE" == false && -n "$target_digest" && "$legacy_subset" == false ]]; then
|
||||
if [[ -z "$recorded_digest" || "$target_digest" != "$recorded_digest" ]]; then
|
||||
warn "$install_name: local files were modified or have legacy unverified state — skipping (use --force once to adopt repository content)"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Perform install
|
||||
if [[ "$install_type" == "command" ]]; then
|
||||
@@ -228,11 +304,13 @@ install_plugin() {
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
dry "$install_name → $COMMANDS_DIR/${install_name}.md"
|
||||
INSTALL_ACTION=true
|
||||
else
|
||||
mkdir -p "$COMMANDS_DIR"
|
||||
cp "$src_md" "$COMMANDS_DIR/${install_name}.md"
|
||||
state_set "$install_name" "$version" "$install_type"
|
||||
state_set "$install_name" "$version" "$install_type" "$source_digest"
|
||||
ok "$install_name → command (v$version)"
|
||||
INSTALL_ACTION=true
|
||||
fi
|
||||
|
||||
else
|
||||
@@ -241,12 +319,14 @@ install_plugin() {
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
dry "$install_name → $dst_dir/"
|
||||
INSTALL_ACTION=true
|
||||
else
|
||||
mkdir -p "$dst_dir"
|
||||
# rsync resolved source (handles nested skills/ structures)
|
||||
rsync -a --delete "$src_dir/" "$dst_dir/"
|
||||
state_set "$install_name" "$version" "$install_type"
|
||||
state_set "$install_name" "$version" "$install_type" "$source_digest"
|
||||
ok "$install_name → skill (v$version)"
|
||||
INSTALL_ACTION=true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -311,15 +391,11 @@ main() {
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be written"
|
||||
[[ -n "$CATEGORY_FILTER" ]] && info "Category filter: $CATEGORY_FILTER"
|
||||
|
||||
local installed=0 skipped=0
|
||||
local installed=0
|
||||
|
||||
while IFS= read -r json_path; do
|
||||
local before
|
||||
before="$(state_all_names | wc -l || true)"
|
||||
install_plugin "$json_path"
|
||||
local after
|
||||
after="$(state_all_names | wc -l || true)"
|
||||
if [[ "$after" -gt "$before" ]] || [[ "$DRY_RUN" == true ]]; then
|
||||
if [[ "$INSTALL_ACTION" == true ]]; then
|
||||
((installed++)) || true
|
||||
fi
|
||||
done < <(find_plugins)
|
||||
|
||||
Reference in New Issue
Block a user