Files
github-spec-kit/.github/workflows/scripts/create-release-packages.sh
ericnoam 8128e6099c feat: add name field injection for forgecode agent
Forgecode requires both 'name' and 'description' fields in command
frontmatter. This commit adds automatic injection of the 'name' field
during command generation for forgecode.

Changes:
- Python (agents.py): Add inject_name: True to forgecode config and
  implement name injection logic in register_commands
- Bash (create-release-packages.sh): Add post-processing step to inject
  name field into frontmatter after command generation

This complements the existing handoffs stripping fix (d83be82) to fully
support forgecode command requirements.
2026-03-31 17:03:56 +02:00

401 lines
16 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" ;;
forgecode)
mkdir -p "$base_dir/.forge/commands"
generate_commands forgecode md "{{parameters}}" "$base_dir/.forge/commands" "$script" "handoffs"
# Inject name field into frontmatter (forgecode 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"
awk -v name="$_cmd_name" 'NR==1 && /^---$/ { print; print "name: "name; next } { 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 forgecode 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