feat(build): generate Claude rule skills and validate build output (Bash)

Bash equivalent of the PowerShell build change: Claude gets general.md
inlined into CLAUDE.md plus one generated skill per rule-skills.tsv entry;
Codex keeps every rule as a plain file with unchanged AGENTS.md text.

Adds the same build-time validation as the PowerShell script: duplicate
agent/plugin/rule-skill names, JSON validity (jq, falling back to a
functional python3 probe), balanced TOML quotes/brackets, and a
tree-wide (not per-file) leftover-placeholder scan for speed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Julian lechner
2026-09-11 15:42:30 +02:00
co-authored by Claude Sonnet 5
parent 11b319c2ea
commit 1807137daa
+129 -9
View File
@@ -5,8 +5,40 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
shared="$root/shared"
output="$root/generated"
plugin_manifest="$root/adapters/plugins.tsv"
rule_skill_manifest="$root/adapters/claude/rule-skills.tsv"
[ -f "$plugin_manifest" ] || { printf 'Missing plugin manifest: %s\n' "$plugin_manifest" >&2; exit 1; }
[ "$(awk 'NR > 1 && NF == 5 { count++ } END { print count + 0 }' "$plugin_manifest")" -eq 4 ] || { printf 'Plugin manifest must define exactly four plugins.\n' >&2; exit 1; }
declare -A plugin_seen
plugin_count=0
while IFS=$'\t' read -r name claude_marketplace claude_plugin codex_marketplace codex_plugin; do
[ "$name" != name ] || continue
[ -n "$name" ] || continue
plugin_count=$((plugin_count + 1))
[ -n "$claude_plugin" ] || { printf 'Missing claude_plugin for plugin %s.\n' "$name" >&2; exit 1; }
[ -n "$codex_plugin" ] || { printf 'Missing codex_plugin for plugin %s.\n' "$name" >&2; exit 1; }
[ -z "${plugin_seen[$name]+x}" ] || { printf 'Duplicate plugin name in manifest: %s\n' "$name" >&2; exit 1; }
plugin_seen[$name]=1
done < "$plugin_manifest"
[ "$plugin_count" -gt 0 ] || { printf 'Plugin manifest must define at least one plugin.\n' >&2; exit 1; }
[ -f "$rule_skill_manifest" ] || { printf 'Missing rule-skill manifest: %s\n' "$rule_skill_manifest" >&2; exit 1; }
declare -A rule_skill_seen
rule_skill_files=()
rule_skill_names=()
rule_skill_triggers=()
while IFS=$'\t' read -r rule_file skill_name trigger; do
[ "$rule_file" != rule_file ] || continue
[ -n "$rule_file" ] || continue
[ -n "$skill_name" ] || { printf 'Missing skill_name in rule-skill manifest row for %s.\n' "$rule_file" >&2; exit 1; }
[ -n "$trigger" ] || { printf 'Missing trigger in rule-skill manifest row for %s.\n' "$rule_file" >&2; exit 1; }
[ -f "$shared/rules/$rule_file" ] || { printf 'Rule-skill manifest references a missing rule file: %s\n' "$rule_file" >&2; exit 1; }
[ -z "${rule_skill_seen[$skill_name]+x}" ] || { printf 'Duplicate rule-skill name in manifest: %s\n' "$skill_name" >&2; exit 1; }
rule_skill_seen[$skill_name]=1
rule_skill_files+=("$rule_file")
rule_skill_names+=("$skill_name")
rule_skill_triggers+=("$trigger")
done < "$rule_skill_manifest"
[ "${#rule_skill_names[@]}" -gt 0 ] || { printf 'Rule-skill manifest must define at least one entry.\n' >&2; exit 1; }
copy_directory() {
local source=$1 destination=$2
@@ -32,29 +64,91 @@ quote_toml() {
bash "$shared/hooks/scripts/test-session-config.sh" "$root"
rm -rf -- "$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
# Codex keeps the original rule-loading text unchanged: every rule ships as a plain
# file and AGENTS.md tells Codex to load the matching one by path.
read -r -d '' codex_rule_loading <<'BLOCK' || true
Always load and apply `rules/general.md` before starting any task.
When programming, always load and apply `rules/security.md`. This includes implementing, modifying, debugging, reviewing, testing, and configuring software, scripts, hooks, infrastructure, and integrations.
Detect the languages, frameworks, tools, and change areas from the repository and the requested work. Load every applicable rule file before editing. Load all matching files when multiple technologies apply.
Load rule files when their subject applies:
- rules/angular.md for Angular work.
- rules/typescript.md for TypeScript work.
- rules/csharp.md for C# or .NET work.
- rules/wpf.md for WPF work.
- rules/ui-ux.md for UI or UX decisions.
- rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work.
- rules/git.md for Git operations.
- rules/refactoring.md for refactoring work.
- rules/definition-of-done.md when validating completion.
- rules/decision-rule.md when requirements, behavior, or technical choices need to be evaluated.
BLOCK
for platform in windows linux; do
mkdir -p "$output/codex-$platform" "$output/claude-$platform"
copy_directory "$shared/skills" "$output/codex-$platform/skills"
copy_directory "$shared/skills" "$output/claude-$platform/skills"
copy_directory "$shared/rules" "$output/codex-$platform/rules"
copy_directory "$shared/rules" "$output/claude-$platform/rules"
copy_directory "$shared/hooks" "$output/codex-$platform/hooks"
copy_directory "$shared/hooks" "$output/claude-$platform/hooks"
copy_directory "$shared/statusline" "$output/claude-$platform/statusline"
cp "$shared/global-instructions.md" "$output/codex-$platform/AGENTS.md"
cp "$shared/global-instructions.md" "$output/claude-$platform/CLAUDE.md"
# Claude: general.md stays a plain, always-applied rule file. Every technology- or
# situation-specific rule becomes a skill instead, so only its name and description
# sit permanently in context; the full text loads only when the skill is invoked.
mkdir -p "$output/claude-$platform/rules"
cp "$shared/rules/general.md" "$output/claude-$platform/rules/general.md"
claude_rule_loading_list=""
for i in "${!rule_skill_names[@]}"; do
rule_file=${rule_skill_files[$i]}
skill_name=${rule_skill_names[$i]}
trigger=${rule_skill_triggers[$i]}
skill_dir="$output/claude-$platform/skills/rules/$skill_name"
mkdir -p "$skill_dir"
{ printf -- '---\nname: %s\ndescription: Use for %s.\n---\n\n' "$skill_name" "$trigger"; cat "$shared/rules/$rule_file"; } > "$skill_dir/SKILL.md"
claude_rule_loading_list="${claude_rule_loading_list}- ${skill_name} for ${trigger}.
"
done
claude_rule_loading="Always apply \`rules/general.md\` before starting any task, embedded below.
Detect the languages, frameworks, tools, and change areas from the repository and the requested work. Invoke every matching rule skill before editing. Invoke all matching rule skills when multiple technologies apply.
Invoke rule skills when their subject applies:
${claude_rule_loading_list%$'\n'}"
shared_template=$(<"$shared/global-instructions.md")
agents_content=${shared_template//__RULE_LOADING__/$codex_rule_loading}
printf '%s\n' "$agents_content" > "$output/codex-$platform/AGENTS.md"
general_content=$(<"$shared/rules/general.md")
claude_content=${shared_template//__RULE_LOADING__/$claude_rule_loading}
printf '%s\n\n---\n\n%s\n' "${claude_content%$'\n'}" "$general_content" > "$output/claude-$platform/CLAUDE.md"
cp "$root/adapters/codex/config/config.toml" "$output/codex-$platform/config.toml"
cp "$root/adapters/claude/config/settings.json" "$output/claude-$platform/settings.json"
for client in codex claude; do
agents_output="$output/$client-$platform/agents"
mkdir -p "$agents_output"
for agent in "$shared"/agents/*; do
metadata="$agent/agent.yml"
for agent_dir in "${agent_dirs[@]}"; do
metadata="${agent_dir}agent.yml"
name=$(read_field "$metadata" name)
description=$(read_field "$metadata" description)
instructions=$(<"$agent/instructions.md")
instructions=$(<"${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
@@ -84,6 +178,32 @@ for client in codex claude; do
content=${content//__STATUSLINE_SCRIPT__/$statusline_script}
printf '%s\n' "$content" > "$path"
done
done
printf 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux\n'
toml_path="$output/codex-$platform/config.toml"
quote_count=$(grep -o '"' "$toml_path" | wc -l)
[ $((quote_count % 2)) -eq 0 ] || { printf 'Generated Codex config.toml has unbalanced quotes: %s\n' "$toml_path" >&2; exit 1; }
open_brackets=$(grep -o '\[' "$toml_path" | wc -l)
close_brackets=$(grep -o '\]' "$toml_path" | wc -l)
[ "$open_brackets" -eq "$close_brackets" ] || { printf 'Generated Codex config.toml has unbalanced brackets: %s\n' "$toml_path" >&2; exit 1; }
settings_path="$output/claude-$platform/settings.json"
if command -v jq >/dev/null 2>&1; then
jq empty "$settings_path" >/dev/null 2>&1 || { printf 'Generated Claude settings.json is not valid JSON: %s\n' "$settings_path" >&2; exit 1; }
elif command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; then
# On Windows, `python3` can resolve to a non-functional Microsoft Store app-execution
# alias that still passes `command -v`; probe it with a real invocation before trusting it.
python3 -c 'import json,sys; json.load(open(sys.argv[1], encoding="utf-8"))' "$settings_path" || { printf 'Generated Claude settings.json is not valid JSON: %s\n' "$settings_path" >&2; exit 1; }
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_]+__' "$output" 2>/dev/null | sort -u | grep -v '^__AI_CONFIG_ROOT__$' || true)
if [ -n "$leftover_tokens" ]; then
offending_files=$(grep -RlE '__[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
printf 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux\n'