Files
bw-copilot-resources/install/publish.sh

502 lines
12 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd -- "$script_dir/.." && pwd -P)"
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
temp_dir=""
usage() {
cat <<'EOF'
Usage: install/publish.sh --source PATH [--kind KIND] [--name NAME] [--force]
Supported kinds:
skill, prompt, instruction, agent, hook
The publish workflow normalizes target names, aligns skill metadata with the
published directory name, and blocks duplicate shared resources by content and
effective display name.
EOF
}
fail() {
printf '%s\n' "$1" >&2
exit 1
}
append_log() {
mkdir -p -- "$state_dir"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$1" \
"$2" \
"$3" \
"$4" \
"$5" \
"$6" >> "$state_dir/publish-log.tsv"
}
normalize_stem() {
local raw="$1"
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
raw="$(printf '%s' "$raw" | sed -E 's/[[:space:]_]+/-/g; s/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
if [[ -z "$raw" ]]; then
raw="resource"
fi
printf '%s\n' "$raw"
}
strip_kind_suffix() {
local kind="$1"
local raw="$2"
case "$kind" in
prompt)
raw="${raw%.prompt.md}"
;;
instruction)
raw="${raw%.instructions.md}"
;;
agent)
raw="${raw%.agent.md}"
;;
hook)
raw="${raw%.json}"
;;
esac
printf '%s\n' "$raw"
}
kind_root_dir() {
local kind="$1"
case "$kind" in
skill)
printf '%s\n' "$repo_root/resources/skills"
;;
prompt)
printf '%s\n' "$repo_root/resources/prompts"
;;
instruction)
printf '%s\n' "$repo_root/resources/instructions"
;;
agent)
printf '%s\n' "$repo_root/resources/agents"
;;
hook)
printf '%s\n' "$repo_root/resources/hooks"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
}
kind_suffix() {
local kind="$1"
case "$kind" in
prompt)
printf '.prompt.md\n'
;;
instruction)
printf '.instructions.md\n'
;;
agent)
printf '.agent.md\n'
;;
hook)
printf '.json\n'
;;
skill)
printf '\n'
;;
*)
fail "Unsupported kind: $kind"
;;
esac
}
extract_frontmatter_field() {
local file="$1"
local field="$2"
[[ -f "$file" ]] || return 0
awk -v field="$field" '
BEGIN {
in_yaml = 0
delimiter_count = 0
}
/^[[:space:]]*---[[:space:]]*$/ {
delimiter_count++
if (delimiter_count == 1) {
in_yaml = 1
next
}
if (in_yaml) {
exit
}
}
in_yaml {
pattern = "^[[:space:]]*" field ":[[:space:]]*"
if ($0 ~ pattern) {
sub(pattern, "", $0)
sub(/[[:space:]]+#.*$/, "", $0)
gsub(/^["\047]|["\047]$/, "", $0)
print $0
exit
}
}
' "$file"
}
ensure_skill_name_field() {
local file="$1"
local normalized_name="$2"
local temp_file
[[ -f "$file" ]] || fail "Skill publish requires SKILL.md: $file"
[[ "$(head -n 1 "$file")" =~ ^---[[:space:]]*$ ]] || fail "Skill frontmatter must start with ---: $file"
temp_file="$(mktemp)"
if ! awk -v normalized_name="$normalized_name" '
BEGIN {
in_yaml = 0
delimiter_count = 0
wrote_name = 0
frontmatter_found = 0
}
/^[[:space:]]*---[[:space:]]*$/ {
delimiter_count++
print
if (delimiter_count == 1) {
in_yaml = 1
frontmatter_found = 1
next
}
if (in_yaml && !wrote_name) {
print "name: " normalized_name
wrote_name = 1
}
in_yaml = 0
next
}
in_yaml {
if ($0 ~ /^[[:space:]]*name:[[:space:]]*/) {
print "name: " normalized_name
wrote_name = 1
next
}
}
{ print }
END {
if (!frontmatter_found || delimiter_count < 2) {
exit 42
}
}
' "$file" > "$temp_file"; then
rm -f -- "$temp_file"
fail "Skill frontmatter is invalid or incomplete: $file"
fi
mv -- "$temp_file" "$file"
}
artifact_fingerprint() {
local path="$1"
if [[ -d "$path" ]]; then
(
cd -- "$path"
find . -type f | LC_ALL=C sort | while IFS= read -r rel_path; do
file_hash="$(shasum -a 256 "$rel_path" | awk '{print $1}')"
printf '%s %s\n' "$file_hash" "$rel_path"
done
) | shasum -a 256 | awk '{print $1}'
return 0
fi
shasum -a 256 "$path" | awk '{print $1}'
}
artifact_display_name() {
local kind="$1"
local path="$2"
local display_name=""
local base_name=""
case "$kind" in
skill)
display_name="$(extract_frontmatter_field "$path/SKILL.md" name)"
if [[ -z "$display_name" ]]; then
display_name="$(basename -- "$path")"
fi
;;
prompt|instruction|agent)
display_name="$(extract_frontmatter_field "$path" name)"
if [[ -z "$display_name" ]]; then
base_name="$(basename -- "$path")"
display_name="$(strip_kind_suffix "$kind" "$base_name")"
fi
;;
hook)
base_name="$(basename -- "$path")"
display_name="${base_name%.json}"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
printf '%s\n' "$display_name"
}
list_kind_artifacts() {
local kind="$1"
local root_dir
root_dir="$(kind_root_dir "$kind")"
[[ -d "$root_dir" ]] || return 0
case "$kind" in
skill)
find "$root_dir" -mindepth 1 -maxdepth 1 -type d | LC_ALL=C sort
;;
prompt)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.prompt.md' | LC_ALL=C sort
;;
instruction)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.instructions.md' | LC_ALL=C sort
;;
agent)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.agent.md' | LC_ALL=C sort
;;
hook)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.json' | LC_ALL=C sort
;;
esac
}
detect_kind() {
local source="$1"
local base
base="$(basename -- "$source")"
if [[ -d "$source" && -f "$source/SKILL.md" ]]; then
printf 'skill\n'
return 0
fi
case "$base" in
*.prompt.md)
printf 'prompt\n'
;;
*.instructions.md)
printf 'instruction\n'
;;
*.agent.md)
printf 'agent\n'
;;
*.json)
printf 'hook\n'
;;
SKILL.md)
printf 'skill\n'
;;
*)
return 1
;;
esac
}
cleanup() {
if [[ -n "${temp_dir:-}" && -d "${temp_dir:-}" ]]; then
rm -rf -- "${temp_dir:-}"
fi
}
main() {
local source=""
local kind=""
local name=""
local force="false"
local candidate_path=""
local normalized_name=""
local target=""
local origin_label="local-first"
local source_base=""
local candidate_fingerprint=""
local candidate_display_name=""
local existing_fingerprint=""
local existing_display_name=""
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
source="$2"
shift 2
;;
--kind)
kind="$2"
shift 2
;;
--name)
name="$2"
shift 2
;;
--force)
force="true"
shift
;;
--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$source" ]] || fail "--source is required"
[[ -e "$source" ]] || fail "Source does not exist: $source"
if [[ -z "$kind" ]]; then
kind="$(detect_kind "$source")" || fail "Could not detect resource kind from source path"
fi
temp_dir="$(mktemp -d)"
case "$kind" in
skill)
if [[ -f "$source" ]]; then
source="$(dirname -- "$source")"
fi
[[ -f "$source/SKILL.md" ]] || fail "Skill publish requires a directory containing SKILL.md"
name="${name:-$(extract_frontmatter_field "$source/SKILL.md" name)}"
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "${name:-$source_base}")"
target="$repo_root/resources/skills/$normalized_name"
candidate_path="$temp_dir/$normalized_name"
cp -R -- "$source" "$candidate_path"
ensure_skill_name_field "$candidate_path/SKILL.md" "$normalized_name"
;;
prompt)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/prompts/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
instruction)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/instructions/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
agent)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/agents/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
hook)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/hooks/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
candidate_fingerprint="$(artifact_fingerprint "$candidate_path")"
candidate_display_name="$(artifact_display_name "$kind" "$candidate_path")"
if [[ -e "$target" ]]; then
existing_fingerprint="$(artifact_fingerprint "$target")"
if [[ "$existing_fingerprint" == "$candidate_fingerprint" ]]; then
append_log "$kind" "$source" "$target" "$origin_label" "$candidate_fingerprint" "noop"
cat <<EOF
No publish needed.
The normalized shared artifact already exists at:
$target
Fingerprint: $candidate_fingerprint
Display name: $candidate_display_name
EOF
return 0
fi
if [[ "$force" != "true" ]]; then
fail "Target already exists with different content: $target"
fi
fi
while IFS= read -r existing_path; do
[[ -n "$existing_path" ]] || continue
if [[ "$existing_path" == "$target" ]]; then
continue
fi
existing_fingerprint="$(artifact_fingerprint "$existing_path")"
if [[ "$existing_fingerprint" == "$candidate_fingerprint" ]]; then
fail "Duplicate $kind content already exists at: $existing_path"
fi
existing_display_name="$(artifact_display_name "$kind" "$existing_path")"
if [[ "$existing_display_name" == "$candidate_display_name" ]]; then
fail "Duplicate $kind display name '$candidate_display_name' already exists at: $existing_path"
fi
done < <(list_kind_artifacts "$kind")
mkdir -p -- "$(dirname -- "$target")"
rm -rf -- "$target"
if [[ -d "$candidate_path" ]]; then
cp -R -- "$candidate_path" "$target"
else
cp -- "$candidate_path" "$target"
fi
append_log "$kind" "$source" "$target" "$origin_label" "$candidate_fingerprint" "published"
cat <<EOF
Published $kind into shared repo:
Source: $source
Target: $target
Fingerprint: $candidate_fingerprint
Display name: $candidate_display_name
Next steps:
1. Review the published artifact.
2. Commit and push the change.
3. Run install/update.sh on other systems or let scheduled sync pick it up.
EOF
}
main "$@"