275 lines
12 KiB
Bash
275 lines
12 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
summary=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--summary) summary=true; shift ;;
|
|
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
shared="$root/shared"
|
|
final="$root/generated"
|
|
sandbox_supported=false
|
|
platform=$(uname -s 2>/dev/null || true)
|
|
case "$platform" in
|
|
Darwin) sandbox_supported=true ;;
|
|
Linux)
|
|
kernel=$(uname -r 2>/dev/null || true)
|
|
case "$kernel" in
|
|
*microsoft*) [[ "$kernel" == *microsoft-standard-WSL2* ]] && sandbox_supported=true ;;
|
|
*) sandbox_supported=true ;;
|
|
esac
|
|
;;
|
|
esac
|
|
sandbox='{"enabled": false}'
|
|
[ "$sandbox_supported" != true ] || sandbox='{"enabled": true, "allowUnsandboxedCommands": false, "failIfUnavailable": true, "network": {"allowedDomains": ["mcp.context7.com"]}}'
|
|
plugin_manifest="$root/adapters/plugins.tsv"
|
|
capability_manifest="$root/adapters/claude/capabilities.tsv"
|
|
|
|
[ -f "$plugin_manifest" ] || { printf 'Missing plugin manifest: %s\n' "$plugin_manifest" >&2; exit 1; }
|
|
awk -F '\t' 'NR == 1 { next } NF == 0 { next } $1 == "" || $3 == "" || $6 == "" { print "Invalid plugin manifest row." > "/dev/stderr"; exit 1 } seen[$1]++ { print "Duplicate plugin name in manifest: " $1 > "/dev/stderr"; exit 1 }' "$plugin_manifest"
|
|
[ -f "$capability_manifest" ] || { printf 'Missing capability manifest: %s\n' "$capability_manifest" >&2; exit 1; }
|
|
declare -A agent_tools
|
|
while IFS=$'\t' read -r role tools; do
|
|
[ "$role" != role ] || continue
|
|
[ -n "$role" ] && [ -n "$tools" ] || { printf 'Invalid capability manifest row.\n' >&2; exit 1; }
|
|
agent_tools[$role]="$tools"
|
|
done < "$capability_manifest"
|
|
|
|
copy_directory() {
|
|
local source=$1 destination=$2
|
|
mkdir -p "$destination"
|
|
cp -R "$source"/. "$destination"/
|
|
}
|
|
|
|
read_field() {
|
|
local path=$1 name=$2 value
|
|
value=$(sed -n "s/^${name}:[[:space:]]*//p" "$path" | head -n 1)
|
|
[ -n "$value" ] || { printf 'Missing %s in %s\n' "$name" "$path" >&2; exit 1; }
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
quote_toml() {
|
|
local value=$1
|
|
value=${value//\\/\\\\}
|
|
value=${value//\"/\\\"}
|
|
value=${value//$'\r'/}
|
|
value=${value//$'\n'/\\n}
|
|
printf '"%s"' "$value"
|
|
}
|
|
|
|
session_output=$(bash "$shared/hooks/scripts/test-session-config.sh" "$root") || { printf '%s\n' "$session_output" >&2; exit 1; }
|
|
if [ "$summary" = true ]; then
|
|
printf '%s\n' "$session_output" | sed '/^PASS session configuration$/d'
|
|
else
|
|
printf '%s\n' "$session_output"
|
|
fi
|
|
# Build into a private sibling directory and swap it into place only after validation, so a failed
|
|
# or parallel build never leaves generated/ partial. Leftovers of crashed runs are removed here:
|
|
# old trees always, build trees only when their Bash owner process has exited.
|
|
# ponytail: PowerShell-owned leftovers are left to the PowerShell build, since PIDs are not comparable across shells.
|
|
for leftover in "$root"/generated.old-* "$root"/generated.build-sh*; do
|
|
[ -d "$leftover" ] || continue
|
|
if [[ "$leftover" == */generated.build-sh* ]]; then
|
|
leftover_pid=${leftover##*/generated.build-sh}
|
|
leftover_pid=${leftover_pid%%-*}
|
|
! kill -0 "$leftover_pid" 2>/dev/null || continue
|
|
fi
|
|
rm -rf -- "$leftover"
|
|
done
|
|
lock="$root/generated.lock"
|
|
locked=false
|
|
retired=''
|
|
output=''
|
|
trap 'rm -rf -- ${output:+"$output"}; [ "$locked" != true ] || rm -f -- "$lock"' EXIT
|
|
# Keep the name short: it lengthens every generated path, which matters on Windows.
|
|
output=$(mktemp -d "$root/generated.build-sh$$-XXXXXX")
|
|
chmod "$(printf '%o' $((0777 & ~$(umask))))" "$output"
|
|
|
|
agent_dirs=("$shared"/agents/*/)
|
|
declare -A agent_seen
|
|
for agent_dir in "${agent_dirs[@]}"; do
|
|
name=$(read_field "${agent_dir}agent.yml" name)
|
|
[ -z "${agent_seen[$name]+x}" ] || { printf 'Duplicate agent name: %s\n' "$name" >&2; exit 1; }
|
|
agent_seen[$name]=1
|
|
done
|
|
|
|
for shell in powershell bash; do
|
|
mkdir -p "$output/codex-$shell" "$output/claude-$shell"
|
|
copy_directory "$shared/skills" "$output/codex-$shell/skills"
|
|
# Claude discovers only skills/<name>/SKILL.md, so flatten the categorized source tree.
|
|
claude_skills="$output/claude-$shell/skills"
|
|
mkdir -p "$claude_skills"
|
|
find "$shared/skills" -maxdepth 1 -type f -exec cp {} "$claude_skills"/ \;
|
|
declare -A claude_skill_names=()
|
|
while IFS= read -r skill_file; do
|
|
skill_source=$(dirname -- "$skill_file")
|
|
skill_name=$(basename -- "$skill_source")
|
|
head -n 6 "$skill_file" | grep -qx "name: $skill_name" || { printf 'Skill name must match its directory: %s\n' "$skill_file" >&2; exit 1; }
|
|
[ -z "${claude_skill_names[$skill_name]+x}" ] || { printf 'Duplicate Claude skill name: %s\n' "$skill_name" >&2; exit 1; }
|
|
claude_skill_names[$skill_name]=1
|
|
copy_directory "$skill_source" "$claude_skills/$skill_name"
|
|
done < <(find "$shared/skills" -type f -name SKILL.md | sort)
|
|
copy_directory "$shared/rules" "$output/codex-$shell/rules"
|
|
copy_directory "$shared/hooks" "$output/codex-$shell/hooks"
|
|
copy_directory "$shared/hooks" "$output/claude-$shell/hooks"
|
|
copy_directory "$shared/statusline" "$output/claude-$shell/statusline"
|
|
|
|
declare -A rule_skill_names=()
|
|
while IFS= read -r rule_path; do
|
|
case "$rule_path" in
|
|
general.md) continue ;;
|
|
*/index.md) skill_name="rules-${rule_path%/index.md}"; rule_body="$shared/rules/$rule_path"; references_dir="${rule_body%/index.md}/references" ;;
|
|
*/*.md)
|
|
rule_directory=${rule_path%%/*}
|
|
rule_relative=${rule_path#*/}
|
|
rule_relative=${rule_relative%.md}
|
|
rule_relative=${rule_relative#references/}
|
|
rule_relative=${rule_relative//\//-}
|
|
skill_name="rules-$rule_directory-$rule_relative"
|
|
rule_body="$shared/rules/$rule_path"
|
|
references_dir=''
|
|
;;
|
|
*.md) skill_name="rules-${rule_path%.md}"; rule_body="$shared/rules/$rule_path"; references_dir='' ;;
|
|
*) continue ;;
|
|
esac
|
|
if [[ -n "${rule_skill_names[$skill_name]:-}" || -n "${claude_skill_names[$skill_name]:-}" ]]; then
|
|
printf 'Duplicate generated rule skill name: %s\n' "$skill_name" >&2
|
|
exit 1
|
|
fi
|
|
rule_skill_names[$skill_name]=1
|
|
skill_dir="$claude_skills/$skill_name"
|
|
mkdir -p "$skill_dir"
|
|
rule_title=$(sed -n 's/^#\{1,6\} *//p' "$rule_body" | head -n 1)
|
|
if [ -z "$rule_title" ]; then printf 'Missing rule title in %s\n' "$rule_body" >&2; exit 1; fi
|
|
rule_description="Use when the task concerns $rule_title."
|
|
{ printf -- '---\nname: %s\ndescription: %s\n---\n\n' "$skill_name" "$(quote_toml "$rule_description")"; cat "$rule_body"; } > "$skill_dir/SKILL.md"
|
|
if [ -n "$references_dir" ] && [ -d "$references_dir" ]; then
|
|
copy_directory "$references_dir" "$skill_dir/references"
|
|
fi
|
|
done < <(find "$shared/rules" -mindepth 1 -type f -name '*.md' -printf '%P\n' | sort)
|
|
|
|
shared_template=$(<"$shared/global-instructions.md")
|
|
general_content=$(<"$shared/rules/general.md")
|
|
credential_helper_extension='sh'
|
|
[ "$shell" != powershell ] || credential_helper_extension='ps1'
|
|
|
|
agents_content=${shared_template//__CLIENT__/codex}
|
|
agents_content=${agents_content//__SHELL__/$credential_helper_extension}
|
|
printf '%s\n\n---\n\n%s\n' "${agents_content%$'\n'}" "$general_content" > "$output/codex-$shell/AGENTS.md"
|
|
|
|
claude_content=${shared_template//__CLIENT__/claude}
|
|
claude_content=${claude_content//__SHELL__/$credential_helper_extension}
|
|
printf '%s\n\n---\n\n%s\n' "${claude_content%$'\n'}" "$general_content" > "$output/claude-$shell/CLAUDE.md"
|
|
|
|
cp "$root/adapters/codex/config/config.toml" "$output/codex-$shell/config.toml"
|
|
cp "$root/adapters/claude/config/settings.json" "$output/claude-$shell/settings.json"
|
|
|
|
for client in codex claude; do
|
|
agents_output="$output/$client-$shell/agents"
|
|
mkdir -p "$agents_output"
|
|
for agent_dir in "${agent_dirs[@]}"; do
|
|
metadata="${agent_dir}agent.yml"
|
|
name=$(read_field "$metadata" name)
|
|
description=$(read_field "$metadata" description)
|
|
instructions=$(<"$root/adapters/$client/orchestration-loader.txt")$'\n\n'$(<"${agent_dir}instructions.md")
|
|
if [ "$client" = codex ]; then
|
|
printf 'name = %s\ndescription = %s\ndeveloper_instructions = %s\n' "$(quote_toml "$name")" "$(quote_toml "$description")" "$(quote_toml "$instructions")" > "$agents_output/$name.toml"
|
|
else
|
|
tools=${agent_tools[$name]:-}
|
|
shell_tool=Bash
|
|
[ "$shell" != powershell ] || shell_tool=PowerShell
|
|
tools=${tools//__SHELL__/$shell_tool}
|
|
if [ -n "$tools" ]; then printf '%s\nname: %s\ndescription: %s\ntools: %s\n%s\n\n%s\n' '---' "$name" "$description" "$tools" '---' "$instructions" > "$agents_output/$name.md"; else printf '%s\nname: %s\ndescription: %s\n%s\n\n%s\n' '---' "$name" "$description" '---' "$instructions" > "$agents_output/$name.md"; fi
|
|
fi
|
|
done
|
|
done
|
|
|
|
for client in codex claude; do
|
|
file=config.toml
|
|
[ "$client" != claude ] || file=settings.json
|
|
command=bash
|
|
script=flashbang.sh
|
|
statusline_script=statusline.sh
|
|
compact_script=record-compact.sh
|
|
pointer_script=show-session-state-pointer.sh
|
|
qmd_warm_script=qmd-warm.sh
|
|
if [ "$shell" = powershell ]; then
|
|
command='__POWERSHELL_COMMAND__'
|
|
script=flashbang.ps1
|
|
statusline_script=statusline.ps1
|
|
compact_script=Record-Compact.ps1
|
|
pointer_script=Show-SessionStatePointer.ps1
|
|
qmd_warm_script=Start-QmdWarm.ps1
|
|
fi
|
|
path="$output/$client-$shell/$file"
|
|
content=$(<"$path")
|
|
# Claude can offer both shell tools on Windows, so every package denies the destructive commands in both.
|
|
deny_rules='["PowerShell(git reset --hard*)","PowerShell(git clean*)","PowerShell(Remove-Item *-Recurse*)","Bash(git reset --hard*)","Bash(git clean*)","Bash(rm -rf*)"]'
|
|
if [ "$shell" = powershell ]; then
|
|
allow_rules='["Agent","PowerShell(mcporter call context7.*)"]'
|
|
else
|
|
allow_rules='["Agent","Bash(mcporter call context7.*)"]'
|
|
fi
|
|
content=${content//__HOOK_COMMAND__/$command}
|
|
content=${content//__POWERSHELL_HOOK_COMMAND__/$command}
|
|
content=${content//__HOOK_SCRIPT__/$script}
|
|
content=${content//__POWERSHELL_HOOK_SCRIPT__/$script}
|
|
content=${content//__COMPACT_SCRIPT__/$compact_script}
|
|
content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script}
|
|
content=${content//__QMD_WARM_SCRIPT__/$qmd_warm_script}
|
|
content=${content//__STATUSLINE_COMMAND__/$command}
|
|
content=${content//__STATUSLINE_SCRIPT__/$statusline_script}
|
|
content=${content//__CLAUDE_SANDBOX__/$sandbox}
|
|
content=${content//__CLAUDE_DENY_RULES__/$deny_rules}
|
|
content=${content//__CLAUDE_ALLOW_RULES__/$allow_rules}
|
|
printf '%s\n' "$content" > "$path"
|
|
done
|
|
|
|
toml_path="$output/codex-$shell/config.toml"
|
|
settings_path="$output/claude-$shell/settings.json"
|
|
python3 "$root/scripts/checks/validate-config.py" "$toml_path" "$settings_path"
|
|
if command -v codex >/dev/null; then
|
|
schema_home=$(mktemp -d)
|
|
cp "$toml_path" "$schema_home/config.toml"
|
|
CODEX_HOME="$schema_home" codex --strict-config --help >/dev/null
|
|
rm -rf -- "$schema_home"
|
|
elif [ "${REQUIRE_CODEX_SCHEMA:-false}" = true ]; then
|
|
printf 'Codex CLI is required for schema validation.\n' >&2
|
|
exit 1
|
|
else
|
|
printf 'WARN Codex CLI unavailable; skipped Codex schema validation.\n' >&2
|
|
fi
|
|
done
|
|
|
|
# __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is
|
|
# expected to remain in generated output, so it is excluded from the leftover check. A
|
|
# single tree-wide grep (rather than one process per file) keeps this fast.
|
|
leftover_tokens=$(grep -RohE '__[A-Z0-9_]*[A-Z0-9][A-Z0-9_]*__' "$output" 2>/dev/null | sort -u | grep -Ev '^(__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__|__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__)$' || true)
|
|
if [ -n "$leftover_tokens" ]; then
|
|
offending_files=$(grep -RlE '__[A-Z0-9_]*[A-Z0-9][A-Z0-9_]*__' "$output" 2>/dev/null | tr '\n' ' ')
|
|
printf 'Unresolved template placeholders (%s) in: %s\n' "$(printf '%s' "$leftover_tokens" | tr '\n' ' ')" "$offending_files" >&2
|
|
exit 1
|
|
fi
|
|
|
|
for ((attempt = 0; attempt < 150; attempt++)); do
|
|
if (set -o noclobber; : > "$lock") 2>/dev/null; then locked=true; break; fi
|
|
sleep 0.2
|
|
done
|
|
[ "$locked" = true ] || { printf 'Another build holds %s; wait for it to finish, or remove the file if no build is running.\n' "$lock" >&2; exit 1; }
|
|
if [ -e "$final" ]; then
|
|
retired="$root/generated.old-sh$$-$RANDOM"
|
|
mv -- "$final" "$retired"
|
|
fi
|
|
if ! mv -- "$output" "$final"; then
|
|
[ -z "$retired" ] || mv -- "$retired" "$final"
|
|
exit 1
|
|
fi
|
|
rm -f -- "$lock"
|
|
locked=false
|
|
[ -z "$retired" ] || rm -rf -- "$retired"
|
|
|
|
if [ "$summary" = true ]; then printf 'Build: PASS | 4 packages\n'; else printf 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash\n'; fi
|