502 lines
12 KiB
Bash
Executable File
502 lines
12 KiB
Bash
Executable File
#!/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 "$@"
|