Files
MyAiConfiguration/scripts/commands/build.sh
T

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