mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
- Fix undefined FORGE_FILE variable in bash update-agent-context.sh - Add missing FORGE_FILE definition pointing to AGENTS.md - Update comment to include Forge in list of agents sharing AGENTS.md - Prevents crash with 'set -u' when running without explicit agent type - Add deduplication logic to PowerShell update-agent-context.ps1 - Implement Update-IfNew helper to track processed files by real path - Prevents AGENTS.md from being rewritten multiple times - Matches existing deduplication behavior in bash script - Prevent duplicate YAML keys in Forge frontmatter injection - Check for existing 'name:' field before injection in both scripts - PowerShell: Parse frontmatter to detect existing name field - Bash: Enhanced awk script to check frontmatter state - Future-proofs against template changes that add name fields All scripts now have consistent behavior and proper error handling.
409 lines
17 KiB
Bash
Executable File
409 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# create-release-packages.sh (workflow-local)
|
|
# Build Spec Kit template release archives for each supported AI assistant and script type.
|
|
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
|
# Version argument should include leading 'v'.
|
|
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
|
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)
|
|
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
|
# Examples:
|
|
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
|
# AGENTS="copilot,gemini" $0 v0.2.0
|
|
# SCRIPTS=ps $0 v0.2.0
|
|
|
|
if [[ $# -ne 1 ]]; then
|
|
echo "Usage: $0 <version-with-v-prefix>" >&2
|
|
exit 1
|
|
fi
|
|
NEW_VERSION="$1"
|
|
if [[ ! $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "Version must look like v0.0.0" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Building release packages for $NEW_VERSION"
|
|
|
|
# Create and use .genreleases directory for all build artifacts
|
|
# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir)
|
|
GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}"
|
|
|
|
# Guard against unsafe GENRELEASES_DIR values before cleaning
|
|
if [[ -z "$GENRELEASES_DIR" ]]; then
|
|
echo "GENRELEASES_DIR must not be empty" >&2
|
|
exit 1
|
|
fi
|
|
case "$GENRELEASES_DIR" in
|
|
'/'|'.'|'..')
|
|
echo "Refusing to use unsafe GENRELEASES_DIR value: $GENRELEASES_DIR" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
if [[ "$GENRELEASES_DIR" == *".."* ]]; then
|
|
echo "Refusing to use GENRELEASES_DIR containing '..' path segments: $GENRELEASES_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$GENRELEASES_DIR"
|
|
rm -rf "${GENRELEASES_DIR%/}/"* || true
|
|
|
|
rewrite_paths() {
|
|
sed -E \
|
|
-e 's@(/?)memory/@.specify/memory/@g' \
|
|
-e 's@(/?)scripts/@.specify/scripts/@g' \
|
|
-e 's@(/?)templates/@.specify/templates/@g' \
|
|
-e 's@\.specify\.specify/@.specify/@g'
|
|
}
|
|
|
|
generate_commands() {
|
|
local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5 extra_strip_key="${6:-}"
|
|
mkdir -p "$output_dir"
|
|
for template in templates/commands/*.md; do
|
|
[[ -f "$template" ]] || continue
|
|
local name description script_command agent_script_command body
|
|
name=$(basename "$template" .md)
|
|
|
|
# Normalize line endings
|
|
file_content=$(tr -d '\r' < "$template")
|
|
|
|
# Extract description and script command from YAML frontmatter
|
|
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
|
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
|
|
|
if [[ -z $script_command ]]; then
|
|
echo "Warning: no script command found for $script_variant in $template" >&2
|
|
script_command="(Missing script command for $script_variant)"
|
|
fi
|
|
|
|
# Extract agent_script command from YAML frontmatter if present
|
|
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
|
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
|
in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ {
|
|
sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "")
|
|
print
|
|
exit
|
|
}
|
|
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
|
')
|
|
|
|
# Replace {SCRIPT} placeholder with the script command
|
|
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
|
|
|
# Replace {AGENT_SCRIPT} placeholder with the agent script command if found
|
|
if [[ -n $agent_script_command ]]; then
|
|
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
|
fi
|
|
|
|
# Remove the scripts:, agent_scripts:, and any extra key sections from frontmatter
|
|
body=$(printf '%s\n' "$body" | awk -v extra_key="$extra_strip_key" '
|
|
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
|
in_frontmatter && /^scripts:$/ { skip_scripts=1; next }
|
|
in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }
|
|
in_frontmatter && extra_key != "" && $0 ~ ("^"extra_key":") { skip_scripts=1; next }
|
|
in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }
|
|
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
|
{ print }
|
|
')
|
|
|
|
# Apply other substitutions; also replace $ARGUMENTS with the agent-specific placeholder
|
|
body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed 's/\$ARGUMENTS/'"$arg_format"'/g' | sed "s/__AGENT__/$agent/g" | rewrite_paths)
|
|
|
|
case $ext in
|
|
toml)
|
|
body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g')
|
|
{ echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;;
|
|
md)
|
|
echo "$body" > "$output_dir/speckit.$name.$ext" ;;
|
|
agent.md)
|
|
echo "$body" > "$output_dir/speckit.$name.$ext" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
generate_copilot_prompts() {
|
|
local agents_dir=$1 prompts_dir=$2
|
|
mkdir -p "$prompts_dir"
|
|
|
|
# Generate a .prompt.md file for each .agent.md file
|
|
for agent_file in "$agents_dir"/speckit.*.agent.md; do
|
|
[[ -f "$agent_file" ]] || continue
|
|
|
|
local basename=$(basename "$agent_file" .agent.md)
|
|
local prompt_file="$prompts_dir/${basename}.prompt.md"
|
|
|
|
cat > "$prompt_file" <<EOF
|
|
---
|
|
agent: ${basename}
|
|
---
|
|
EOF
|
|
done
|
|
}
|
|
|
|
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
|
# Skills use hyphenated names (e.g. speckit-plan).
|
|
#
|
|
# Technical debt note:
|
|
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
|
# overrides (at minimum: name/description/compatibility/metadata.{author,source}).
|
|
create_skills() {
|
|
local skills_dir="$1"
|
|
local script_variant="$2"
|
|
local agent_name="$3"
|
|
local separator="${4:-"-"}"
|
|
|
|
for template in templates/commands/*.md; do
|
|
[[ -f "$template" ]] || continue
|
|
local name
|
|
name=$(basename "$template" .md)
|
|
local skill_name="speckit${separator}${name}"
|
|
local skill_dir="${skills_dir}/${skill_name}"
|
|
mkdir -p "$skill_dir"
|
|
|
|
local file_content
|
|
file_content=$(tr -d '\r' < "$template")
|
|
|
|
# Extract description from frontmatter
|
|
local description
|
|
description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}')
|
|
[[ -z "$description" ]] && description="Spec Kit: ${name} workflow"
|
|
|
|
# Extract script command
|
|
local script_command
|
|
script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}')
|
|
[[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)"
|
|
|
|
# Extract agent_script command from frontmatter if present
|
|
local agent_script_command
|
|
agent_script_command=$(printf '%s\n' "$file_content" | awk '
|
|
/^agent_scripts:$/ { in_agent_scripts=1; next }
|
|
in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ {
|
|
sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "")
|
|
print
|
|
exit
|
|
}
|
|
in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }
|
|
')
|
|
|
|
# Build body: replace placeholders, strip scripts sections, rewrite paths
|
|
local body
|
|
body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g")
|
|
if [[ -n $agent_script_command ]]; then
|
|
body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g")
|
|
fi
|
|
body=$(printf '%s\n' "$body" | awk '
|
|
/^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }
|
|
in_frontmatter && /^scripts:$/ { skip_scripts=1; next }
|
|
in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }
|
|
in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }
|
|
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
|
{ print }
|
|
')
|
|
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
|
|
|
|
# Strip existing frontmatter and prepend skills frontmatter.
|
|
local template_body
|
|
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
|
|
|
{
|
|
printf -- '---\n'
|
|
printf 'name: "%s"\n' "$skill_name"
|
|
printf 'description: "%s"\n' "$description"
|
|
printf 'compatibility: "%s"\n' "Requires spec-kit project structure with .specify/ directory"
|
|
printf -- 'metadata:\n'
|
|
printf ' author: "%s"\n' "github-spec-kit"
|
|
printf ' source: "%s"\n' "templates/commands/${name}.md"
|
|
printf -- '---\n\n'
|
|
printf '%s\n' "$template_body"
|
|
} > "$skill_dir/SKILL.md"
|
|
done
|
|
}
|
|
|
|
build_variant() {
|
|
local agent=$1 script=$2
|
|
local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}"
|
|
echo "Building $agent ($script) package..."
|
|
mkdir -p "$base_dir"
|
|
|
|
# Copy base structure but filter scripts by variant
|
|
SPEC_DIR="$base_dir/.specify"
|
|
mkdir -p "$SPEC_DIR"
|
|
|
|
[[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; }
|
|
|
|
# Only copy the relevant script variant directory
|
|
if [[ -d scripts ]]; then
|
|
mkdir -p "$SPEC_DIR/scripts"
|
|
case $script in
|
|
sh)
|
|
[[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; }
|
|
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
|
;;
|
|
ps)
|
|
[[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; }
|
|
find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
[[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" | while IFS= read -r f; do d="$SPEC_DIR/$(dirname "$f")"; mkdir -p "$d"; cp "$f" "$d/"; done; echo "Copied templates -> .specify/templates"; }
|
|
|
|
case $agent in
|
|
claude)
|
|
mkdir -p "$base_dir/.claude/commands"
|
|
generate_commands claude md "\$ARGUMENTS" "$base_dir/.claude/commands" "$script" ;;
|
|
gemini)
|
|
mkdir -p "$base_dir/.gemini/commands"
|
|
generate_commands gemini toml "{{args}}" "$base_dir/.gemini/commands" "$script"
|
|
[[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md "$base_dir/GEMINI.md" ;;
|
|
copilot)
|
|
mkdir -p "$base_dir/.github/agents"
|
|
generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script"
|
|
generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts"
|
|
mkdir -p "$base_dir/.vscode"
|
|
[[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json"
|
|
;;
|
|
cursor-agent)
|
|
mkdir -p "$base_dir/.cursor/commands"
|
|
generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;;
|
|
qwen)
|
|
mkdir -p "$base_dir/.qwen/commands"
|
|
generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script"
|
|
[[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;;
|
|
opencode)
|
|
mkdir -p "$base_dir/.opencode/command"
|
|
generate_commands opencode md "\$ARGUMENTS" "$base_dir/.opencode/command" "$script" ;;
|
|
windsurf)
|
|
mkdir -p "$base_dir/.windsurf/workflows"
|
|
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
|
junie)
|
|
mkdir -p "$base_dir/.junie/commands"
|
|
generate_commands junie md "\$ARGUMENTS" "$base_dir/.junie/commands" "$script" ;;
|
|
codex)
|
|
mkdir -p "$base_dir/.agents/skills"
|
|
create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
|
|
kilocode)
|
|
mkdir -p "$base_dir/.kilocode/workflows"
|
|
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
|
auggie)
|
|
mkdir -p "$base_dir/.augment/commands"
|
|
generate_commands auggie md "\$ARGUMENTS" "$base_dir/.augment/commands" "$script" ;;
|
|
roo)
|
|
mkdir -p "$base_dir/.roo/commands"
|
|
generate_commands roo md "\$ARGUMENTS" "$base_dir/.roo/commands" "$script" ;;
|
|
codebuddy)
|
|
mkdir -p "$base_dir/.codebuddy/commands"
|
|
generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;;
|
|
qodercli)
|
|
mkdir -p "$base_dir/.qoder/commands"
|
|
generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;;
|
|
amp)
|
|
mkdir -p "$base_dir/.agents/commands"
|
|
generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;;
|
|
shai)
|
|
mkdir -p "$base_dir/.shai/commands"
|
|
generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;;
|
|
tabnine)
|
|
mkdir -p "$base_dir/.tabnine/agent/commands"
|
|
generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script"
|
|
[[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;;
|
|
kiro-cli)
|
|
mkdir -p "$base_dir/.kiro/prompts"
|
|
generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;;
|
|
agy)
|
|
mkdir -p "$base_dir/.agent/commands"
|
|
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;;
|
|
bob)
|
|
mkdir -p "$base_dir/.bob/commands"
|
|
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
|
|
vibe)
|
|
mkdir -p "$base_dir/.vibe/prompts"
|
|
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
|
kimi)
|
|
mkdir -p "$base_dir/.kimi/skills"
|
|
create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;;
|
|
trae)
|
|
mkdir -p "$base_dir/.trae/rules"
|
|
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
|
pi)
|
|
mkdir -p "$base_dir/.pi/prompts"
|
|
generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;;
|
|
iflow)
|
|
mkdir -p "$base_dir/.iflow/commands"
|
|
generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;;
|
|
forge)
|
|
mkdir -p "$base_dir/.forge/commands"
|
|
generate_commands forge md "{{parameters}}" "$base_dir/.forge/commands" "$script" "handoffs"
|
|
# Inject name field into frontmatter (forge requires name + description)
|
|
for _cmd_file in "$base_dir/.forge/commands/"*.md; do
|
|
[[ -f "$_cmd_file" ]] || continue
|
|
_cmd_name=$(basename "$_cmd_file" .md)
|
|
_tmp_file="${_cmd_file}.tmp"
|
|
# Only inject name if frontmatter doesn't already have one
|
|
awk -v name="$_cmd_name" '
|
|
BEGIN { in_frontmatter=0; has_name=0; first_dash_seen=0 }
|
|
NR==1 && /^---$/ { in_frontmatter=1; first_dash_seen=1; print; next }
|
|
in_frontmatter && /^---$/ { in_frontmatter=0 }
|
|
in_frontmatter && /^[ \t]*name[ \t]*:/ { has_name=1 }
|
|
first_dash_seen && !has_name && NR==2 && in_frontmatter { print "name: "name }
|
|
{ print }
|
|
' "$_cmd_file" > "$_tmp_file"
|
|
mv "$_tmp_file" "$_cmd_file"
|
|
done ;;
|
|
generic)
|
|
mkdir -p "$base_dir/.speckit/commands"
|
|
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
|
esac
|
|
( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . )
|
|
echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip"
|
|
}
|
|
|
|
# Determine agent list
|
|
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow forge generic)
|
|
ALL_SCRIPTS=(sh ps)
|
|
|
|
validate_subset() {
|
|
local type=$1; shift
|
|
local allowed_str="$1"; shift
|
|
local invalid=0
|
|
for it in "$@"; do
|
|
local found=0
|
|
for a in $allowed_str; do
|
|
if [[ "$it" == "$a" ]]; then found=1; break; fi
|
|
done
|
|
if [[ $found -eq 0 ]]; then
|
|
echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2
|
|
invalid=1
|
|
fi
|
|
done
|
|
return $invalid
|
|
}
|
|
|
|
read_list() { tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?" ":"") $i);out=1}}}END{printf("\n")}'; }
|
|
|
|
if [[ -n ${AGENTS:-} ]]; then
|
|
read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)"
|
|
validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1
|
|
else
|
|
AGENT_LIST=("${ALL_AGENTS[@]}")
|
|
fi
|
|
|
|
if [[ -n ${SCRIPTS:-} ]]; then
|
|
read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)"
|
|
validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1
|
|
else
|
|
SCRIPT_LIST=("${ALL_SCRIPTS[@]}")
|
|
fi
|
|
|
|
echo "Agents: ${AGENT_LIST[*]}"
|
|
echo "Scripts: ${SCRIPT_LIST[*]}"
|
|
|
|
for agent in "${AGENT_LIST[@]}"; do
|
|
for script in "${SCRIPT_LIST[@]}"; do
|
|
build_variant "$agent" "$script"
|
|
done
|
|
done
|
|
|
|
echo "Archives in $GENRELEASES_DIR:"
|
|
ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip
|