#!/usr/bin/env bash## Install or audit the Claude GitHub Action workflow on a target repo, or# bulk-set the CLAUDE_CODE_OAUTH_TOKEN or R2 image-upload secrets on many# repos at once.## Usage:# sync-claude-action.sh setup-tokens [--discover] [--yes] [OWNER/REPO...]# sync-claude-action.sh setup-r2 [--discover] [--yes] [OWNER/REPO...]# sync-claude-action.sh install OWNER/REPO# sync-claude-action.sh audit OWNER/REPO## `setup-tokens` runs `claude setup-token` ONCE (browser auth), captures the# resulting OAuth token IN MEMORY ONLY (never written to disk), validates it,# then loops `gh secret set CLAUDE_CODE_OAUTH_TOKEN` over every named repo.# One browser auth, one new token, N secret writes. Use this for initial# rollout and for annual token rotation across all managed repos.## `setup-r2` writes R2 image-upload secrets to each named repo. The three# required secrets (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ACCOUNT_ID)# must be set in the environment. Two optional secrets (R2_BUCKET,# R2_PUBLIC_URL) override the bucket name and public base URL; when omitted# the workflow defaults to `img` and `https://img.chriscalo.com`. Values# come from environment variables of the same names — held in memory only,# never written to disk.## Flags:# --discover Auto-find managed repos via `gh search code --filename# claude.yaml`. Use for annual rotation across the whole set# of repos that already have the workflow installed.# --yes Skip the "Proceed? [y/N]" confirmation prompt.## `install` sets up permissions, label, and workflow PR for one repo:# 1. Flip Actions workflow permissions to write + allow PR creation# 2. Create the `claude` label (used by the workflow's label trigger)# 3. Open a PR adding/replacing .github/workflows/claude.yaml with the# canonical version from this directory# Assumes the OAuth token secret is already set (run `setup-tokens` first).## `audit` fetches the repo's current workflow file and asks `claude` to# describe how it differs from canonical in plain language. Doesn't decide# anything — operator reads the summary and chooses what to do.## For batch install, wrap in a shell loop:# for r in repo1 repo2 repo3; do# sync-claude-action.sh install "chriscalo/$r"# doneset -euo pipefailSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"CANONICAL_WORKFLOW="$SCRIPT_DIR/claude-action-workflow.yaml"CANONICAL_EXPIRY_WORKFLOW="$SCRIPT_DIR/claude-token-expiry-workflow.yaml"REPO_BASE="https://github.com/chriscalo/dev-skills/blob/main"CANONICAL_WORKFLOW_URL="$REPO_BASE/skills/github/claude-action-workflow.yaml"CANONICAL_DOC_URL="$REPO_BASE/skills/github/claude-action.md#tool-policy"RULE="────────────────────────────────────────────────────────────────────"log() { echo "$@" >&2; }banner() { log; log "$RULE"; log " $1"; log "$RULE"; }section() { log; log "→ $1"; }ok() { log " ✓ $1"; }warn() { log " ! $1"; }fail_msg() { log " ✗ $1"; }indented() { log " $1"; }usage() { cat >&2 <<EOFUsage: $(basename "$0") setup-tokens [--discover] [--yes] [OWNER/REPO...] $(basename "$0") setup-r2 [--discover] [--yes] [OWNER/REPO...] $(basename "$0") install OWNER/REPO $(basename "$0") audit OWNER/REPO setup-tokens One OAuth token written to N repos. --discover auto-finds every repo that already has claude.yaml installed. setup-r2 R2 image-upload secrets written to N repos. Reads R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_ACCOUNT_ID (required) plus R2_BUCKET / R2_PUBLIC_URL (optional) from env. --discover auto-finds installed repos. install Set up permissions, label, open workflow PR for one repo. audit Show how a repo's workflow differs from canonical.EOF exit 2}require_command() { if ! command -v "$1" >/dev/null 2>&1; then log "error: $1 not found in PATH" exit 1 fi}require_canonical() { if [ ! -f "$CANONICAL_WORKFLOW" ]; then log "error: canonical workflow missing at $CANONICAL_WORKFLOW" exit 1 fi if [ ! -f "$CANONICAL_EXPIRY_WORKFLOW" ]; then log "error: canonical expiry workflow template missing at $CANONICAL_EXPIRY_WORKFLOW" exit 1 fi}install_expiry_workflow() { local repo="$1" local issued_at lifetime_days expires_on issued_at=$(date +%Y-%m-%d) lifetime_days=365 expires_on=$(date -d "$issued_at + $lifetime_days days" +%Y-%m-%d) # Compute 7 notification dates and build cron schedule entries. # Each entry fires on a specific D M date once per year. setup-tokens # rewrites this workflow file on each rotation, replacing stale entries. local offsets=(30 15 7 3 2 1 0) local day_labels=("30 days before expiry" "15 days before expiry" \ "7 days before expiry" "3 days before expiry" "2 days before expiry" \ "1 day before expiry" "expiry day") local cron_schedule="" local i for i in 0 1 2 3 4 5 6; do local offset="${offsets[$i]}" local label="${day_labels[$i]}" local notify_date notify_date=$(date -d "$expires_on - $offset days" +%Y-%m-%d) local d m d=$(date -d "$notify_date" +%-d) m=$(date -d "$notify_date" +%-m) # Build with actual newlines so the multi-line string passes to awk cleanly cron_schedule="${cron_schedule} - cron: '0 9 $d $m *' # $label ($notify_date)" done # Substitute three placeholders in the canonical template: # __SCHEDULE_ENTRIES__ → the 7 cron lines built above # __ISSUED_AT__ → ISO issue date (e.g. "2026-06-01") # __EXPIRES_ON__ → ISO expiry date (e.g. "2027-06-01") # ENVIRON avoids -v escape-interpretation ambiguity across awk implementations. local workflow_yaml workflow_b64 existing_sha export _CLAUDE_EXPIRY_CRON="$cron_schedule" workflow_yaml=$(awk -v issued="$issued_at" -v expires="$expires_on" ' /^ # __SCHEDULE_ENTRIES__$/ { printf "%s", ENVIRON["_CLAUDE_EXPIRY_CRON"] next } { gsub(/__ISSUED_AT__/, issued) gsub(/__EXPIRES_ON__/, expires) print } ' "$CANONICAL_EXPIRY_WORKFLOW") unset _CLAUDE_EXPIRY_CRON workflow_b64=$(printf '%s' "$workflow_yaml" | base64 | tr -d '\n') local workflow_path=".github/workflows/claude-token-expiry.yaml" existing_sha=$(gh api "repos/$repo/contents/$workflow_path" \ --jq '.sha' 2>/dev/null || true) if [ -n "$existing_sha" ]; then gh api -X PUT "repos/$repo/contents/$workflow_path" \ -f message="Update claude-token-expiry.yaml (notifications for $expires_on)" \ -f content="$workflow_b64" \ -f sha="$existing_sha" >/dev/null 2>&1 else gh api -X PUT "repos/$repo/contents/$workflow_path" \ -f message="Add claude-token-expiry.yaml (notifications for $expires_on)" \ -f content="$workflow_b64" >/dev/null 2>&1 fi # Migration: delete leftover claude-token-state.json if it exists. # The expiry workflow used to read this file at runtime; the new template # bakes the dates in directly. One rotation cycle migrates each repo. local state_sha state_sha=$(gh api "repos/$repo/contents/.github/claude-token-state.json" \ --jq '.sha' 2>/dev/null || true) if [ -n "$state_sha" ]; then gh api -X DELETE "repos/$repo/contents/.github/claude-token-state.json" \ -f message="Drop claude-token-state.json (dates now baked into expiry workflow)" \ -f sha="$state_sha" >/dev/null 2>&1 \ || indented "warning: failed to delete claude-token-state.json (non-fatal)" fi # Close any open claude-token-expiry issues — stale after rotation. local open_issue open_issue=$(gh issue list \ --repo "$repo" \ --label "claude-token-expiry" \ --state open \ --json number \ --jq '.[0].number // ""' 2>/dev/null || true) if [ -n "$open_issue" ]; then gh issue close "$open_issue" \ --repo "$repo" \ --comment "Token rotated on $issued_at. New expiry: $expires_on. Notification schedule reset for the new expiry date." \ 2>/dev/null || indented "warning: failed to close old expiry issue (non-fatal)" fi}flip_permissions() { local repo="$1" local result perm pr result=$(gh api "repos/$repo/actions/permissions/workflow" 2>/dev/null \ || true) perm=$(echo "$result" | jq -r '.default_workflow_permissions // ""') pr=$(echo "$result" | jq -r '.can_approve_pull_request_reviews // ""') if [ "$perm" = "write" ] && [ "$pr" = "true" ]; then ok "Actions permissions already write + allow PR creation" return fi gh api -X PUT "repos/$repo/actions/permissions/workflow" \ -F default_workflow_permissions=write \ -F can_approve_pull_request_reviews=true >/dev/null ok "Flipped Actions permissions to write + allow PR creation"}create_label() { local repo="$1" gh label create claude \ --description "Trigger Claude Code Action" \ --color FF6B35 \ --force \ --repo "$repo" >/dev/null ok "'claude' label exists (created or updated)"}open_workflow_pr() ( # Subshell isolates the trap and `cd` from the parent shell. set -euo pipefail local repo="$1" local tmpdir tmpdir=$(mktemp -d -t claude-sync-XXXXXX) trap "rm -rf '$tmpdir'" EXIT gh repo clone "$repo" "$tmpdir/repo" -- --depth=1 --quiet ok "Cloned to $tmpdir/repo" cd "$tmpdir/repo" local default_branch default_branch=$(git rev-parse --abbrev-ref HEAD) local action="install" if [ -f .github/workflows/claude.yaml ] || \ [ -f .github/workflows/claude.yml ]; then action="standardize" fi local branch="claude/${action}-claude-action-workflow" # If a prior sync left the branch on the remote (typically because the PR # was never merged or closed), check it out and commit on top instead of # starting fresh from the default branch. Without this, the later push # fails as non-fast-forward and the script aborts. local existing_pr_url="" if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then git fetch origin "$branch" --depth=2 --quiet git checkout -b "$branch" FETCH_HEAD >/dev/null 2>&1 existing_pr_url=$(gh pr list --repo "$repo" --head "$branch" \ --state open --json url --jq '.[0].url // empty') if [ -n "$existing_pr_url" ]; then ok "Refreshing existing branch $branch (open PR: $existing_pr_url)" else ok "Reusing existing branch $branch (no open PR)" fi else git checkout -b "$branch" >/dev/null 2>&1 ok "Created branch $branch" fi mkdir -p .github/workflows cp "$CANONICAL_WORKFLOW" .github/workflows/claude.yaml rm -f .github/workflows/claude.yml local needs_commit=true if git diff --quiet && [ -z "$(git status --porcelain)" ]; then # Working tree already matches branch HEAD (canonical was already present). # With an open PR the branch is fine as-is. In the stale-branch/no-open-PR # case the branch may carry canonical from a prior closed PR while the # default branch was never updated — only skip when the default branch is # also already up-to-date. if [ -n "$existing_pr_url" ] || \ git diff --quiet "origin/$default_branch" -- .github/workflows/claude.yaml 2>/dev/null; then warn "Workflow already matches canonical — no PR needed" return fi # Stale branch already has canonical; push it as-is and open a new PR. warn "Branch has canonical workflow but no open PR — opening PR" needs_commit=false fi if [ "$needs_commit" = true ]; then ok "Wrote .github/workflows/claude.yaml ($action)" git add .github/workflows/claude.yaml git add .github/workflows/claude.yml 2>/dev/null || true fi local commit_msg if [ "$needs_commit" = true ]; then if [ -n "$existing_pr_url" ]; then commit_msg="Refresh canonical workflow" elif [ "$action" = "install" ]; then commit_msg="Install Claude GitHub Action workflow" else commit_msg="Standardize Claude GitHub Action workflow" fi git commit -q -m "$commit_msgAdds (or replaces) .github/workflows/claude.yaml with the canonicalworkflow from chriscalo/dev-skills:skills/github/claude-action-workflow.yaml.Tool policy: --allowedTools \"Read,Grep,Glob,Edit,Write,Skill,Task,Agent,Bash(*),WebFetch,WebSearch\"with a targeted --disallowedTools list (force-push, push-to-main, branch/repo/release delete, mutating gh api, npm publish, gh secret/auth ops). Seechriscalo/dev-skills:skills/github/claude-action.md section 'Tool policy'for rationale." fi git push -u origin "$branch" --quiet ok "Pushed branch to origin" if [ -n "$existing_pr_url" ]; then ok "PR refreshed: $existing_pr_url" return fi local pr_title if [ "$action" = "install" ]; then pr_title="Install Claude GitHub Action workflow" else pr_title="Standardize Claude GitHub Action workflow" fi local pr_body pr_body="Adds (or replaces) \`.github/workflows/claude.yaml\` with thecanonical workflow from[chriscalo/dev-skills]($CANONICAL_WORKFLOW_URL).**Tool policy**: \`--allowedTools \"Read,Grep,Glob,Edit,Write,Skill,Task,Agent,Bash(*),WebFetch,WebSearch\"\`with a targeted \`--disallowedTools\` list. See[claude-action.md \"Tool policy\"]($CANONICAL_DOC_URL)for rationale.Future drift can be detected via\`sync-claude-action.sh audit OWNER/REPO\`." local pr_url pr_url=$(gh pr create \ --repo "$repo" \ --base "$default_branch" \ --head "$branch" \ --title "$pr_title" \ --body "$pr_body") ok "PR opened: $pr_url")discover_managed_repos() { local owner owner=$(gh api user --jq '.login') gh search code --filename claude.yaml --owner "$owner" \ --json repository --jq '.[].repository.nameWithOwner' | sort -u}cmd_setup_tokens() { local discover=false local yes=false local repos=() local arg for arg in "$@"; do case "$arg" in --discover) discover=true ;; --yes|-y) yes=true ;; --*) log "error: unknown flag: $arg" usage ;; */*) repos+=("$arg") ;; *) log "error: invalid repo arg (expected OWNER/REPO): $arg" usage ;; esac done if [ "$discover" = true ]; then if [ ${#repos[@]} -gt 0 ]; then log "error: --discover and explicit repos are mutually exclusive" exit 2 fi log "Discovering managed repos via gh search..." local discovered discovered=$(discover_managed_repos) if [ -z "$discovered" ]; then log "error: --discover found no repos with claude.yaml installed" exit 1 fi while IFS= read -r line; do repos+=("$line") done <<< "$discovered" fi if [ ${#repos[@]} -eq 0 ]; then log "error: setup-tokens needs at least one OWNER/REPO or --discover" usage fi banner "Setup CLAUDE_CODE_OAUTH_TOKEN on ${#repos[@]} repo(s)" log "This will issue ONE new OAuth token via 'claude setup-token'" log "(browser auth) and write it as the CLAUDE_CODE_OAUTH_TOKEN secret" log "on each repo below. The token is held in memory only — never on" log "disk. Existing tokens on these repos will be overwritten." log log "Repos to update:" local repo i=0 width width=${#repos[@]} width=${#width} for repo in "${repos[@]}"; do i=$((i + 1)) printf " %${width}d. %s\n" "$i" "$repo" >&2 done log if [ "$yes" != true ]; then local reply read -r -p "Proceed? [y/N] " reply case "$reply" in [yY]|[yY][eE][sS]) ;; *) log "aborted" exit 1 ;; esac fi section "Generating OAuth token via 'claude setup-token' (browser auth)" local token token=$(claude setup-token \ | grep -oE 'sk-ant-oat[0-9]+-[A-Za-z0-9_-]{50,}' | head -1) if [ -z "$token" ]; then fail_msg "no sk-ant-oat token matched in setup-token output" indented "the token format may have changed —" indented "check Anthropic docs and update the regex" exit 1 fi if [ "${#token}" -lt 80 ] || [ "${#token}" -gt 256 ]; then fail_msg "extracted token has suspicious length (${#token})" exit 1 fi ok "Token captured: ${token:0:14}... (${#token} chars), held in memory only" section "Writing secret to ${#repos[@]} repo(s)" local total=${#repos[@]} local pad=${#total} local n=0 ok_count=0 fail_count=0 local failed=() for repo in "${repos[@]}"; do n=$((n + 1)) if gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$repo" \ --body "$token" 2>/dev/null; then printf " [%${pad}d/%d] ✓ %s\n" "$n" "$total" "$repo" >&2 ok_count=$((ok_count + 1)) if install_expiry_workflow "$repo" 2>/dev/null; then indented "wrote expiry workflow" else indented "warning: failed to write expiry workflow (non-fatal)" fi else printf " [%${pad}d/%d] ✗ %s\n" "$n" "$total" "$repo" >&2 fail_count=$((fail_count + 1)) failed+=("$repo") fi done unset token section "Discarding token from memory" ok "Token cleared" banner "Result: $ok_count succeeded, $fail_count failed" if [ "$fail_count" -gt 0 ]; then log "Failed repos:" for repo in "${failed[@]}"; do log " $repo" done log log "Re-run with the failed repos to retry. The succeeded repos already" log "have the new token; re-running on them just overwrites with the same" log "(or another new) token." exit 1 fi log log "Next steps:" log " • Install/standardize the workflow on each repo:" log " sync-claude-action.sh install OWNER/REPO" log " • For batch install, wrap in a shell loop:" log " for r in repo1 repo2 ...; do" log " sync-claude-action.sh install \"OWNER/\$r\"" log " done" log " • Audit drift on existing installs:" log " sync-claude-action.sh audit OWNER/REPO"}cmd_setup_r2() { local discover=false local yes=false local repos=() local arg for arg in "$@"; do case "$arg" in --discover) discover=true ;; --yes|-y) yes=true ;; --*) log "error: unknown flag: $arg" usage ;; */*) repos+=("$arg") ;; *) log "error: invalid repo arg (expected OWNER/REPO): $arg" usage ;; esac done if [ "$discover" = true ]; then if [ ${#repos[@]} -gt 0 ]; then log "error: --discover and explicit repos are mutually exclusive" exit 2 fi log "Discovering managed repos via gh search..." local discovered discovered=$(discover_managed_repos) if [ -z "$discovered" ]; then log "error: --discover found no repos with claude.yaml installed" exit 1 fi while IFS= read -r line; do repos+=("$line") done <<< "$discovered" fi if [ ${#repos[@]} -eq 0 ]; then log "error: setup-r2 needs at least one OWNER/REPO or --discover" usage fi # Pull values from env (memory only — never written to disk by this # script). The three required vars authenticate to R2; the two optional # vars override bucket name and public URL (defaults: img / # https://img.chriscalo.com). local missing=() [ -n "${R2_ACCESS_KEY_ID:-}" ] || missing+=(R2_ACCESS_KEY_ID) [ -n "${R2_SECRET_ACCESS_KEY:-}" ] || missing+=(R2_SECRET_ACCESS_KEY) [ -n "${R2_ACCOUNT_ID:-}" ] || missing+=(R2_ACCOUNT_ID) if [ ${#missing[@]} -gt 0 ]; then log "error: missing required env vars: ${missing[*]}" log log "Export the R2 credentials before running, e.g.:" log " export R2_ACCESS_KEY_ID=..." log " export R2_SECRET_ACCESS_KEY=..." log " export R2_ACCOUNT_ID=..." log "Optionally also set R2_BUCKET (default: img) and R2_PUBLIC_URL" log "(default: https://img.chriscalo.com) to target a different bucket." log "Get the access key + secret from a Cloudflare R2 API token with" log "Object Read & Write on the bucket; the account ID is in the" log "Cloudflare dashboard URL or in any R2 endpoint URL." exit 1 fi local secret_count=3 [ -n "${R2_BUCKET:-}" ] && secret_count=$((secret_count + 1)) [ -n "${R2_PUBLIC_URL:-}" ] && secret_count=$((secret_count + 1)) banner "Setup R2 image-upload secrets on ${#repos[@]} repo(s)" log "Writes R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ACCOUNT_ID" [ -n "${R2_BUCKET:-}" ] && log " R2_BUCKET" [ -n "${R2_PUBLIC_URL:-}" ] && log " R2_PUBLIC_URL" log "to each repo below. Values come from environment variables of the" log "same names — held in memory only, never written to disk by this" log "script. Existing secrets on these repos will be overwritten." log log "Repos to update:" local repo i=0 width width=${#repos[@]} width=${#width} for repo in "${repos[@]}"; do i=$((i + 1)) printf " %${width}d. %s\n" "$i" "$repo" >&2 done log if [ "$yes" != true ]; then local reply read -r -p "Proceed? [y/N] " reply case "$reply" in [yY]|[yY][eE][sS]) ;; *) log "aborted" exit 1 ;; esac fi section "Writing $secret_count secrets per repo to ${#repos[@]} repo(s)" local total=${#repos[@]} local pad=${#total} local n=0 ok_count=0 fail_count=0 local failed=() for repo in "${repos[@]}"; do n=$((n + 1)) local repo_ok=true gh secret set R2_ACCESS_KEY_ID --repo "$repo" \ --body "$R2_ACCESS_KEY_ID" 2>/dev/null || repo_ok=false gh secret set R2_SECRET_ACCESS_KEY --repo "$repo" \ --body "$R2_SECRET_ACCESS_KEY" 2>/dev/null || repo_ok=false gh secret set R2_ACCOUNT_ID --repo "$repo" \ --body "$R2_ACCOUNT_ID" 2>/dev/null || repo_ok=false if [ -n "${R2_BUCKET:-}" ]; then gh secret set R2_BUCKET --repo "$repo" \ --body "$R2_BUCKET" 2>/dev/null || repo_ok=false fi if [ -n "${R2_PUBLIC_URL:-}" ]; then gh secret set R2_PUBLIC_URL --repo "$repo" \ --body "$R2_PUBLIC_URL" 2>/dev/null || repo_ok=false fi if [ "$repo_ok" = true ]; then printf " [%${pad}d/%d] ✓ %s\n" "$n" "$total" "$repo" >&2 ok_count=$((ok_count + 1)) else printf " [%${pad}d/%d] ✗ %s\n" "$n" "$total" "$repo" >&2 fail_count=$((fail_count + 1)) failed+=("$repo") fi done banner "Result: $ok_count succeeded, $fail_count failed" if [ "$fail_count" -gt 0 ]; then log "Failed repos:" for repo in "${failed[@]}"; do log " $repo" done log log "Re-run with the failed repos to retry. Partial success on a repo" log "(some secrets set, others not) is possible — re-running is safe;" log "it overwrites each secret with the same value." exit 1 fi log log "Next steps:" log " • Verify by opening a PR in one of the repos and asking Claude" log " to post a screenshot. The image should render inline." log " • If a repo's workflow doesn't have the R2 env block yet," log " re-sync to canonical:" log " sync-claude-action.sh install OWNER/REPO"}cmd_install() { local repo="$1" banner "Install Claude Action workflow on $repo" section "[1/3] Checking Actions workflow permissions" flip_permissions "$repo" section "[2/3] Ensuring 'claude' label exists" create_label "$repo" section "[3/3] Opening workflow PR" open_workflow_pr "$repo" banner "Done" log log "Next steps:" log " • Review and merge the PR (or close it to skip)." log " • Test the action by mentioning @claude in an issue, or by" log " applying the 'claude' label to an issue." log " • If the action fails to authenticate, confirm setup-tokens has" log " run on this repo (CLAUDE_CODE_OAUTH_TOKEN secret must exist)."}cmd_audit() { local repo="$1" banner "Audit $repo workflow against canonical" section "Fetching .github/workflows/claude.yaml" local current current=$(gh api "repos/$repo/contents/.github/workflows/claude.yaml" \ --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) local found_as=".github/workflows/claude.yaml" if [ -z "$current" ]; then current=$(gh api "repos/$repo/contents/.github/workflows/claude.yml" \ --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) found_as=".github/workflows/claude.yml" fi if [ -z "$current" ]; then warn "No workflow file found in $repo" log log "Next steps:" log " • Install the workflow:" log " sync-claude-action.sh install $repo" return fi ok "Found $found_as (${#current} bytes)" section "Asking claude to summarize differences vs canonical" local canonical canonical=$(cat "$CANONICAL_WORKFLOW") local prompt prompt="You are summarizing the difference between two GitHub Actionsworkflow files. Describe in plain language how the REPO version differsfrom the CANONICAL version. Don't decide what to do — just describe.Focus on functional differences (auth method, triggers, permissions,allowedTools/disallowedTools, action versions). Ignore comments andwhitespace.CANONICAL:$canonicalREPO:$current" log echo "$prompt" | claude -p log banner "Done" log log "Next steps:" log " • If the diff matters: sync-claude-action.sh install $repo" log " (the install subcommand replaces an existing workflow with" log " canonical via PR)" log " • If the diff is intentional: leave as-is. Note the reason for" log " future you."}main() { if [ $# -lt 1 ]; then usage fi require_command gh require_command jq require_command git require_canonical local cmd="$1" shift case "$cmd" in setup-tokens) require_command claude cmd_setup_tokens "$@" ;; setup-r2) cmd_setup_r2 "$@" ;; install) if [ $# -lt 1 ]; then usage; fi cmd_install "$1" ;; audit) require_command claude if [ $# -lt 1 ]; then usage; fi cmd_audit "$1" ;; *) usage ;; esac}main "$@"