diff --git a/justfile b/justfile index 5b57e1d..6900bad 100644 --- a/justfile +++ b/justfile @@ -158,3 +158,27 @@ alias tb := template-backport template-backport-all *args: bash scripts/template_backport_all.sh {{args}} alias tba := template-backport-all + +# ---------------------------------------------------------------------------- # +# COUSINS # +# ---------------------------------------------------------------------------- # + +# Review one cousin repo (by name from scripts/cousins.json) for template-sourced improvements (dry-run by default; --execute to run) +cousin-review *args: + bash scripts/cousin_review.sh {{args}} +alias cr := cousin-review + +# Review every cousin in scripts/cousins.json in parallel (dry-run by default; --execute to run) +cousin-review-all *args: + bash scripts/cousin_review_all.sh {{args}} +alias cra := cousin-review-all + +# Apply template-sourced changes to a cousin's opted-in paths and open a PR (dry-run by default; --execute to run) +cousin-apply *args: + bash scripts/cousin_apply.sh {{args}} +alias ca := cousin-apply + +# Apply changes to every cousin, one PR each (dry-run by default; --execute to run) +cousin-apply-all *args: + bash scripts/cousin_apply_all.sh {{args}} +alias caa := cousin-apply-all diff --git a/scripts/cousin_apply.sh b/scripts/cousin_apply.sh new file mode 100755 index 0000000..9dc6cf2 --- /dev/null +++ b/scripts/cousin_apply.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROMPT_DIR="${SCRIPT_DIR}/cousin_apply" +DEFAULT_TEMPLATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=lib/cousins.sh +source "${SCRIPT_DIR}/lib/cousins.sh" +# shellcheck source=lib/pr_prompt.sh +source "${SCRIPT_DIR}/lib/pr_prompt.sh" +cousins__require_jq + +# --- Locate a JS package runner (pnpm dlx preferred, npx fallback) ---------- +find_runner() { + if command -v pnpm &>/dev/null; then + echo "pnpm dlx" + elif command -v npx &>/dev/null; then + echo "npx" + else + echo "Error: neither pnpm nor npx found. Install one of them first." >&2 + exit 1 + fi +} +RUNNER="$(find_runner)" + +# --- Parse arguments -------------------------------------------------------- +EXECUTE=false +COUSIN_NAME="" + +usage() { + cat >&2 < + +Applies high-confidence template-sourced changes to a cousin's opted-in paths, +runs per-target checks, and opens a single PR against the cousin repo. + +This script ALWAYS clones the cousin fresh into a tempdir so your local +working copies are never touched. There is no local-path override. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --execute) EXECUTE=true; shift ;; + --cousin) COUSIN_NAME="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$COUSIN_NAME" ]]; then + usage + exit 2 +fi + +CONFIG_PATH="$(cousins_config_path "$SCRIPT_DIR")" +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "Error: cousins config not found at ${CONFIG_PATH}" >&2 + exit 1 +fi + +COUSIN_CONFIG_JSON="$(cousins_get_by_name "$CONFIG_PATH" "$COUSIN_NAME")" +if [[ -z "$COUSIN_CONFIG_JSON" ]]; then + echo "Error: no cousin named '${COUSIN_NAME}' in ${CONFIG_PATH}" >&2 + exit 1 +fi + +# Refuse to run if the cousin has no opted-in targets — this is a no-op. +TARGET_COUNT="$(jq '(.targets // []) | length + ((.global_applies // []) | length)' <<<"$COUSIN_CONFIG_JSON")" +if [[ "$TARGET_COUNT" -eq 0 ]]; then + echo "Cousin '${COUSIN_NAME}' has no targets or global_applies — nothing to do." + exit 0 +fi + +COUSIN_URL="$(jq -r '.url' <<<"$COUSIN_CONFIG_JSON")" +TEMPLATE_DIR="${TEMPLATE_DIR:-$DEFAULT_TEMPLATE_DIR}" + +compose_prompt() { + local prompt="" + if [[ -f "${PROMPT_DIR}/role.md" ]]; then + prompt+="$(cat "${PROMPT_DIR}/role.md")" + prompt+=$'\n\n' + fi + for f in "${PROMPT_DIR}"/*.md; do + [[ "$(basename "$f")" == "role.md" ]] && continue + prompt+="$(cat "$f")" + prompt+=$'\n\n' + done + echo "$prompt" +} +PROMPT="$(compose_prompt)" + +# Apply mode: Claude edits files, runs checks, and creates a PR via gh. +ALLOWED_TOOLS="Read Edit Write Bash" + +if [[ "$EXECUTE" == true ]]; then + WORK_DIR="$(mktemp -d)" + CLONE_PATH="${WORK_DIR}/${COUSIN_NAME}" + echo "Runner: ${RUNNER}" + echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})" + echo "Clone path: ${CLONE_PATH}" + echo "Template: ${TEMPLATE_DIR}" + echo "Prompt length: ${#PROMPT} chars" + echo "Allowed tools: ${ALLOWED_TOOLS}" + echo "---" + + git clone --quiet "$COUSIN_URL" "$CLONE_PATH" + + cd "$CLONE_PATH" + export TEMPLATE_DIR COUSIN_CONFIG_JSON COUSIN_NAME + OUTPUT="$(echo "${PROMPT}" | ${RUNNER} @anthropic-ai/claude-code --print \ + --allowed-tools ${ALLOWED_TOOLS})" + echo "$OUTPUT" + + PR_URL="$(pr_prompt_extract_url "$OUTPUT")" + if [[ -n "$PR_URL" ]]; then + pr_prompt_finalize "$PR_URL" + else + echo "No PR opened (no changes survived, or run was a no-op)." + fi + + echo "Clone left at: ${CLONE_PATH}" +else + echo "=== DRY RUN ===" + echo + echo "${PROMPT}" + echo "---" + echo "Runner: ${RUNNER}" + echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})" + echo "Template: ${TEMPLATE_DIR}" + echo "Config: ${CONFIG_PATH}" + echo "Prompt length: ${#PROMPT} chars" + echo "Allowed tools: ${ALLOWED_TOOLS}" + echo + echo "Pass --execute to actually run (clones the cousin fresh into a tempdir)." +fi diff --git a/scripts/cousin_apply/role.md b/scripts/cousin_apply/role.md new file mode 100644 index 0000000..dc46c6b --- /dev/null +++ b/scripts/cousin_apply/role.md @@ -0,0 +1,3 @@ +You are a pragmatic senior Rust engineer applying a small number of template-sourced improvements to explicitly-whitelisted Rust subpaths inside a third-party "cousin" repository. + +The cousin is NOT a fork of the template. It is its own project with its own conventions. You are a guest here. You may only touch files that the cousin's config file has explicitly opted-in, and you must never touch anything outside those slots. When in doubt, SKIP — it is much better to open a tiny, obviously-correct PR than a large or ambiguous one. diff --git a/scripts/cousin_apply/task.md b/scripts/cousin_apply/task.md new file mode 100644 index 0000000..da2dbd9 --- /dev/null +++ b/scripts/cousin_apply/task.md @@ -0,0 +1,108 @@ +## Context + +You are running inside a **clone of a cousin repository** (your current working directory). Your job is to apply a small set of high-confidence, template-sourced improvements — limited strictly to the opted-in slots in `COUSIN_CONFIG_JSON` — and open a single PR for this cousin containing all surviving changes. + +Environment variables: + +- `TEMPLATE_DIR` — absolute path to a local checkout of the template repo (the source of bytes to copy). +- `COUSIN_CONFIG_JSON` — full JSON object describing THIS cousin. Schema documented in `scripts/cousin_review/task.md`. +- `COUSIN_NAME` — short name, safe for branch names and PR titles. + +## Hard safety rules + +1. **Opt-in only.** The only file slots you may read, diff, or modify: + - For each `targets[i]`: files listed in `targets[i].applies`, resolved at path `targets[i].path/` inside the cousin. + - Files in `global_applies`, resolved at the cousin repo root. + Anything else is off-limits. Do not edit, delete, rename, or create anything outside these slots. + +2. **Forbidden paths win.** If a candidate path matches any pattern in `forbidden_paths`, drop it immediately. + +3. **Never retype bytes.** Propagate changes by `cp "$TEMPLATE_DIR/" "/"`. Never use the `Write` tool to reconstruct a template file from memory — that produces bogus full-file diffs from subtle formatting drift. + +4. **One PR per cousin, total.** All surviving changes across all targets go in a single branch and a single PR. + +5. **If a check fails, revert the offending change.** Do not try to "fix up" the cousin's surrounding code. Your mandate is limited to the opted-in slots. + +## Task + +### 1. Parse the config and verify constraints + +``` +echo "$COUSIN_CONFIG_JSON" | jq . +``` + +Build two flat lists: +- `SLOTS` — the full set of allowed file slots from rule 1 above, as absolute paths inside the cousin. +- `FORBIDDEN` — the `forbidden_paths` globs. + +Remove any SLOT whose path matches any FORBIDDEN glob. If the SLOTS list is now empty, print `No opted-in slots for ${COUSIN_NAME}.` and exit without branching or committing. + +### 2. Diff each slot against the template + +For each slot, run: + +``` +diff -u "" "$TEMPLATE_DIR/