rollout-cli 0.3.2__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. rollout_cli-0.4.1/.claude/skills/mass-update/SKILL.md +109 -0
  2. rollout_cli-0.4.1/.claude/skills/mass-update/scripts/mass-update.sh +410 -0
  3. rollout_cli-0.4.1/.devague/frames/mass-update-now-clones-each-target-repo-into-a-git.json +214 -0
  4. rollout_cli-0.4.1/.devague/plans/mass-update-now-clones-each-target-repo-into-a-git.json +255 -0
  5. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.gitignore +3 -0
  6. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/CHANGELOG.md +23 -0
  7. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/CLAUDE.md +12 -8
  8. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/PKG-INFO +1 -1
  9. rollout_cli-0.4.1/docs/plans/2026-06-12-mass-update-now-clones-each-target-repo-into-a-git.md +72 -0
  10. rollout_cli-0.4.1/docs/specs/2026-06-12-mass-update-now-clones-each-target-repo-into-a-git.md +54 -0
  11. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/pyproject.toml +1 -1
  12. rollout_cli-0.4.1/tests/test_mass_update.py +489 -0
  13. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/uv.lock +1 -1
  14. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/SKILL.md +0 -0
  15. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  16. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/scripts/show.sh +0 -0
  17. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/SKILL.md +0 -0
  18. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
  19. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/review.md +0 -0
  20. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/write.md +0 -0
  21. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
  22. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  23. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  24. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/SKILL.md +0 -0
  25. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  26. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  27. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  28. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  29. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  30. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/SKILL.md +0 -0
  31. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  32. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  33. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  34. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  35. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  36. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  37. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  38. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  39. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  40. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  41. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/run-tests/SKILL.md +0 -0
  42. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/run-tests/scripts/test.sh +0 -0
  43. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/sonarclaude/SKILL.md +0 -0
  44. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  45. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  46. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  47. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/think/SKILL.md +0 -0
  48. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/think/scripts/think.sh +0 -0
  49. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/version-bump/SKILL.md +0 -0
  50. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/version-bump/scripts/bump.py +0 -0
  51. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills.local.yaml.example +0 -0
  52. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.devague/frames/rollout-cli-ships-rollout-define-an-org-wide-chang.json +0 -0
  53. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.devague/plans/rollout-cli-ships-rollout-define-an-org-wide-chang.json +0 -0
  54. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.flake8 +0 -0
  55. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.github/workflows/publish.yml +0 -0
  56. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.github/workflows/tests.yml +0 -0
  57. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.markdownlint-cli2.yaml +0 -0
  58. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/AGENTS.colleague.md +0 -0
  59. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/LICENSE +0 -0
  60. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/README.md +0 -0
  61. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/culture.yaml +0 -0
  62. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/plans/2026-06-06-rollout-cli-ships-rollout-define-an-org-wide-chang.md +0 -0
  63. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/skill-sources.md +0 -0
  64. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/specs/2026-06-06-rollout-cli-ships-rollout-define-an-org-wide-chang.md +0 -0
  65. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/apply.sh +0 -0
  66. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/check.sh +0 -0
  67. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/files/LICENSE +0 -0
  68. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/manifest.toml +0 -0
  69. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/README.md +0 -0
  70. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/apply.sh +0 -0
  71. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/check.sh +0 -0
  72. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/SKILL.md.tmpl +0 -0
  73. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/explore.md +0 -0
  74. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/review.md +0 -0
  75. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/write.md +0 -0
  76. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/scripts/ask-colleague.sh +0 -0
  77. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/manifest.toml +0 -0
  78. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/__init__.py +0 -0
  79. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/__main__.py +0 -0
  80. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/__init__.py +0 -0
  81. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/__init__.py +0 -0
  82. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/apply.py +0 -0
  83. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/cli.py +0 -0
  84. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/doctor.py +0 -0
  85. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/explain.py +0 -0
  86. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/learn.py +0 -0
  87. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/overview.py +0 -0
  88. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/plan.py +0 -0
  89. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/whoami.py +0 -0
  90. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_errors.py +0 -0
  91. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_output.py +0 -0
  92. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/__init__.py +0 -0
  93. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/bump.py +0 -0
  94. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/devex_pr.py +0 -0
  95. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/gitops.py +0 -0
  96. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/orchestrate.py +0 -0
  97. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/preflight.py +0 -0
  98. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/recipe.py +0 -0
  99. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/report.py +0 -0
  100. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/targets.py +0 -0
  101. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/explain/__init__.py +0 -0
  102. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/explain/catalog.py +0 -0
  103. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/sonar-project.properties +0 -0
  104. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/__init__.py +0 -0
  105. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_apache_relicense.py +0 -0
  106. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_apply_cmd.py +0 -0
  107. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_bump.py +0 -0
  108. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli.py +0 -0
  109. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli_introspection.py +0 -0
  110. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli_wiring.py +0 -0
  111. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_devex_pr.py +0 -0
  112. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_e2e_apache_relicense.py +0 -0
  113. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_explain_catalog.py +0 -0
  114. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_gitops.py +0 -0
  115. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_orchestrate.py +0 -0
  116. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_plan_cmd.py +0 -0
  117. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_preflight.py +0 -0
  118. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_recipe.py +0 -0
  119. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_report.py +0 -0
  120. {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_targets.py +0 -0
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: mass-update
3
+ type: command
4
+ description: >
5
+ Propagate ONE deterministic change across many GitHub repos at once. Targets
6
+ are GitHub slugs (`owner/repo`), not local paths. For each target, mass-update
7
+ clones fresh into a scratch dir, applies a verbatim file drop (`--file`) or
8
+ anchored edit (`--edit`), and cleans up on exit. Default is PREVIEW (zero
9
+ outward effect); `--pr` pushes a branch, opens a PR, and files a companion
10
+ tracking issue. No LLM, no ask-colleague — increment 1 is deterministic only.
11
+ Triggers when the user says "mass update", "propagate this change across
12
+ repos", "apply X to all these repos", "roll this out to agentculture/foo", or
13
+ "relicense these repos". One call = one PR per repo; run it several times for
14
+ independent changes.
15
+ ---
16
+
17
+ # mass-update — one deterministic change, many repos
18
+
19
+ `mass-update` is a **deterministic** cross-repo propagator. It clones each target
20
+ repo fresh into a scratch directory, applies verbatim file drops or anchored
21
+ edits, and cleans up on exit. No LLM, no `ask-colleague`.
22
+
23
+ ## What changed
24
+
25
+ mass-update previously drove `ask-colleague` against sibling local checkouts
26
+ (worktrees, `colleague/<id>` branches, `--apply`/`--pr` landing). That flow is
27
+ gone. The new mechanism:
28
+
29
+ - **Targets are GitHub slugs** (`owner/repo`), not local paths. A local path is
30
+ rejected.
31
+ - For each target it **clones fresh** into a gitignored scratch dir
32
+ (`.mass-update/clones/<owner>-<repo>/`) inside rollout-cli, branches off the
33
+ default branch, applies a deterministic change in the clone, and cleans the
34
+ clone up on exit (success or failure). `--keep-clone` retains it for debugging.
35
+ - The cardinal rule: **rollout-cli never reaches beyond its own repo.** The
36
+ entire on-disk footprint stays inside rollout-cli; the only outward effects
37
+ are `git push` (the PR) and `/communicate` (the issue).
38
+ - Increment 1 supports only deterministic ops: `--file` (verbatim) and
39
+ `--edit` (anchored unique-occurrence replace). Free-text described changes are
40
+ a deferred follow-up.
41
+
42
+ ## How to run
43
+
44
+ ```bash
45
+ bash .claude/skills/mass-update/scripts/mass-update.sh \
46
+ --repos <owner/repo[,owner/repo...]> <ops> [--pr] [--keep-clone]
47
+ ```
48
+
49
+ Run it from the rollout-cli repo root. `--repos` takes comma-separated GitHub
50
+ slugs (e.g. `agentculture/widget-cli`).
51
+
52
+ ## Op shapes (combinable in one call)
53
+
54
+ | Op | What it does |
55
+ |----|--------------|
56
+ | `--file DEST --from SRC` | Create/replace `DEST` in each clone with **exactly** `SRC`'s bytes (verbatim). Use this for content the model shouldn't regenerate — a `LICENSE`, a CI workflow, a config file. |
57
+ | `--file DEST --content '...'` | Same, but the verbatim content is given inline. |
58
+ | `--edit 'FILE: FIND => REPLACE'` | Replace the **unique** occurrence of `FIND` with `REPLACE` in `FILE`. Errors on 0 or >1 matches. Repeatable. |
59
+
60
+ All ops given in one call are applied to each clone. At least one op is required.
61
+
62
+ ## Landing — preview or PR
63
+
64
+ | Flag | Effect |
65
+ |------|--------|
66
+ | *(none)* | **PREVIEW.** Clone, apply ops, print diff, cleanup. **Zero outward effect.** Always start here. |
67
+ | `--pr` | Clone, apply, commit, push, open PR, file a companion tracking issue via `communicate/scripts/post-issue.sh`, cleanup. Both the PR URL and issue URL are printed. |
68
+ | `--keep-clone` | Retain the scratch clone on exit (debug). |
69
+
70
+ ## Worked example — propagate a LICENSE to a sibling repo
71
+
72
+ Replace `agentculture/widget-cli`'s `LICENSE` with rollout-cli's own `LICENSE`,
73
+ then open a PR and file a tracking issue:
74
+
75
+ ```bash
76
+ bash .claude/skills/mass-update/scripts/mass-update.sh \
77
+ --repos agentculture/widget-cli \
78
+ --file LICENSE --from ./LICENSE \
79
+ --pr
80
+ ```
81
+
82
+ Run it **without `--pr` first** to inspect the preview diff; re-run with `--pr`
83
+ once the diff is correct.
84
+
85
+ ## Where it fits
86
+
87
+ - **`rollout` engine** — deterministic recipes, reproducible, no model. Reach for
88
+ it for org-wide fan-out (`--org`) and exact, scriptable transforms.
89
+ - **`mass-update`** — *this skill*: deterministic cross-repo propagation via
90
+ fresh clones, for verbatim file drops and anchored edits across a handful of
91
+ GitHub repos.
92
+
93
+ ## Honest limits
94
+
95
+ - **Deterministic only (increment 1).** Free-text described changes are NOT
96
+ supported — they are a deferred follow-up that needs an `ask-colleague`
97
+ edit-in-place mode.
98
+ - **One drive per target.** Each target succeeds or fails independently; a
99
+ non-zero exit means at least one target failed (others may have landed).
100
+ Re-run for the failures.
101
+ - **Requires `gh` and `git`.** Both must be on PATH and authenticated
102
+ (`gh auth status` must succeed) before any clone begins.
103
+ - **`--keep-clone`** retains the scratch directory for debugging; without it,
104
+ clones are cleaned up on both success and failure.
105
+
106
+ ## Provenance
107
+
108
+ mass-update is a rollout-cli-local skill. The `cite, don't import` policy holds:
109
+ downstream repos copy it, they don't symlink or depend on it.
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # mass-update — deterministic mass-update across GitHub repos.
4
+ #
5
+ # Clones each target repo into a scratch dir, applies deterministic ops,
6
+ # and optionally opens a PR + tracking issue. No LLM, no ask-colleague.
7
+ #
8
+ set -euo pipefail
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ mass-update — deterministic mass-update across GitHub repos.
13
+
14
+ Usage:
15
+ mass-update --repos OWNER/REPO[,OWNER/REPO...] <ops> [--pr] [--keep-clone] [-h|--help]
16
+
17
+ Ops (at least one required):
18
+ --file DEST --from SRC Write DEST with verbatim bytes of SRC (relative to cwd).
19
+ --file DEST --content TEXT Write DEST with verbatim TEXT.
20
+ --edit 'FILE: FIND => REPLACE' Replace UNIQUE occurrence of FIND with REPLACE in FILE.
21
+
22
+ Landing:
23
+ (default) PREVIEW: clone, apply ops, print diff, cleanup. No push/PR/issue.
24
+ --pr clone, apply, commit, push, open PR, file tracking issue, cleanup.
25
+ --keep-clone Retain the scratch clone on exit (debug).
26
+
27
+ Targets:
28
+ --repos a/b,c/d Comma-separated GitHub slugs (owner/repo). Required.
29
+
30
+ Other:
31
+ -h, --help Show this help.
32
+ EOF
33
+ }
34
+
35
+ # Guard a value-taking flag.
36
+ need_value() {
37
+ [[ "$1" -ge 2 ]] || { echo "error: $2 requires a value" >&2; echo "hint: run 'mass-update --help'" >&2; exit 2; }
38
+ }
39
+
40
+ # ── defaults + accumulators ──────────────────────────────────────────────────
41
+ REPOS_CSV=""
42
+ FILE_DESTS=()
43
+ FILE_KINDS=()
44
+ FILE_SRCS=()
45
+ FILE_CONTENTS=()
46
+ EDITS=()
47
+ DO_PR=0
48
+ KEEP_CLONE=0
49
+ PENDING_FILE=""
50
+
51
+ _push_file() {
52
+ [[ -n "$PENDING_FILE" ]] || { echo "error: $([[ "$1" == "from" ]] && echo --from || echo --content) given before any --file DEST" >&2; exit 2; }
53
+ FILE_DESTS+=("$PENDING_FILE")
54
+ FILE_KINDS+=("$1")
55
+ if [[ "$1" == "from" ]]; then
56
+ FILE_SRCS+=("$2")
57
+ FILE_CONTENTS+=("")
58
+ else
59
+ FILE_SRCS+=("")
60
+ FILE_CONTENTS+=("$2")
61
+ fi
62
+ PENDING_FILE=""
63
+ }
64
+
65
+ # ── parse arguments ─────────────────────────────────────────────────────────
66
+ POSITIONAL_ARGS=()
67
+
68
+ while [[ $# -gt 0 ]]; do
69
+ case "$1" in
70
+ --repos)
71
+ need_value "$#" "$1"
72
+ REPOS_CSV="$2"
73
+ shift 2
74
+ ;;
75
+ --file)
76
+ need_value "$#" "$1"
77
+ [[ -z "$PENDING_FILE" ]] || { echo "error: --file $PENDING_FILE has no --from/--content before the next --file" >&2; exit 2; }
78
+ PENDING_FILE="$2"
79
+ shift 2
80
+ ;;
81
+ --from)
82
+ need_value "$#" "$1"
83
+ _push_file from "$2"
84
+ shift 2
85
+ ;;
86
+ --content)
87
+ need_value "$#" "$1"
88
+ _push_file content "$2"
89
+ shift 2
90
+ ;;
91
+ --edit)
92
+ need_value "$#" "$1"
93
+ EDITS+=("$2")
94
+ shift 2
95
+ ;;
96
+ --pr)
97
+ DO_PR=1
98
+ shift
99
+ ;;
100
+ --keep-clone)
101
+ KEEP_CLONE=1
102
+ shift
103
+ ;;
104
+ -h|--help)
105
+ usage
106
+ exit 0
107
+ ;;
108
+ -*)
109
+ echo "error: unknown option '$1'" >&2
110
+ echo "hint: run 'mass-update --help'" >&2
111
+ exit 2
112
+ ;;
113
+ *)
114
+ POSITIONAL_ARGS+=("$1")
115
+ shift
116
+ ;;
117
+ esac
118
+ done
119
+
120
+ [[ -z "$PENDING_FILE" ]] || { echo "error: --file $PENDING_FILE is missing its --from SRC or --content '...'" >&2; exit 2; }
121
+
122
+ # Reject positional free-text — it's a deferred follow-up.
123
+ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then
124
+ echo "error: positional free-text is not supported in this version" >&2
125
+ echo "hint: free-text is a deferred follow-up; use --file or --edit for deterministic ops" >&2
126
+ exit 2
127
+ fi
128
+
129
+ # Require at least one op.
130
+ if [[ ${#FILE_DESTS[@]} -eq 0 && ${#EDITS[@]} -eq 0 ]]; then
131
+ echo "error: no change requested — pass at least one --file or --edit" >&2
132
+ echo "hint: run 'mass-update --help'" >&2
133
+ exit 2
134
+ fi
135
+
136
+ # ── validate slug format ────────────────────────────────────────────────────
137
+ validate_slug() {
138
+ local slug="$1"
139
+ # Must match OWNER/REPO pattern with valid chars
140
+ if [[ ! "$slug" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
141
+ echo "error: '$slug' is not a valid GitHub slug (owner/repo)" >&2
142
+ echo "hint: slugs must match OWNER/REPO format, e.g. 'agentculture/widget-cli'" >&2
143
+ return 1
144
+ fi
145
+ # Reject local paths that happen to contain a slash
146
+ if [[ "$slug" == ../* || "$slug" == ./* || "$slug" == /* ]]; then
147
+ echo "error: '$slug' is not a valid GitHub slug — looks like a local path" >&2
148
+ echo "hint: slugs must be GitHub OWNER/REPO, not filesystem paths" >&2
149
+ return 1
150
+ fi
151
+ return 0
152
+ }
153
+
154
+ # Reject a DEST/FILE path that could escape the scratch clone — absolute paths
155
+ # or any '..' path segment. This enforces the cardinal rule: every write stays
156
+ # inside the clone, never reaching beyond the repo.
157
+ validate_relpath() {
158
+ local p="$1" what="$2"
159
+ if [[ "$p" == /* ]]; then
160
+ echo "error: $what '$p' must be a repo-relative path, not absolute" >&2
161
+ echo "hint: targets must stay inside the scratch clone" >&2
162
+ return 1
163
+ fi
164
+ if [[ "$p" == ".." || "$p" == "../"* || "$p" == *"/.." || "$p" == *"/../"* ]]; then
165
+ echo "error: $what '$p' may not contain a '..' path segment" >&2
166
+ echo "hint: targets must stay inside the scratch clone" >&2
167
+ return 1
168
+ fi
169
+ return 0
170
+ }
171
+
172
+ # ── check preconditions ─────────────────────────────────────────────────────
173
+ if ! command -v gh >/dev/null 2>&1; then
174
+ echo "error: gh not found on PATH" >&2
175
+ echo "hint: install the GitHub CLI (gh) to use mass-update" >&2
176
+ exit 1
177
+ fi
178
+
179
+ if ! command -v git >/dev/null 2>&1; then
180
+ echo "error: git not found on PATH" >&2
181
+ echo "hint: install git to use mass-update" >&2
182
+ exit 1
183
+ fi
184
+
185
+ # ── parse repos ─────────────────────────────────────────────────────────────
186
+ IFS=',' read -r -a TARGETS <<< "$REPOS_CSV"
187
+
188
+ # Require at least one non-empty slug — an absent/blank --repos is an error,
189
+ # not a silent no-op that exits 0.
190
+ nonempty_targets=0
191
+ for raw in ${TARGETS[@]+"${TARGETS[@]}"}; do
192
+ t="${raw#"${raw%%[![:space:]]*}"}"
193
+ t="${t%"${t##*[![:space:]]}"}"
194
+ [[ -n "$t" ]] && nonempty_targets=$((nonempty_targets + 1))
195
+ done
196
+ if [[ "$nonempty_targets" -eq 0 ]]; then
197
+ echo "error: no target repos — pass --repos OWNER/REPO[,...]" >&2
198
+ echo "hint: at least one GitHub slug is required" >&2
199
+ exit 2
200
+ fi
201
+
202
+ # Validate all slugs first
203
+ for raw in "${TARGETS[@]}"; do
204
+ target="${raw#"${raw%%[![:space:]]*}"}"
205
+ target="${target%"${target##*[![:space:]]}"}"
206
+ [[ -n "$target" ]] || continue
207
+ if ! validate_slug "$target"; then
208
+ exit 2
209
+ fi
210
+ done
211
+
212
+ # Validate every destination/target path BEFORE any clone, so a traversal
213
+ # attempt never even creates the scratch dir.
214
+ for ((i=0; i<${#FILE_DESTS[@]}; i++)); do
215
+ validate_relpath "${FILE_DESTS[$i]}" "--file DEST" || exit 2
216
+ done
217
+ for ((i=0; i<${#EDITS[@]}; i++)); do
218
+ edit_file="${EDITS[$i]%%: *}"
219
+ validate_relpath "$edit_file" "--edit FILE" || exit 2
220
+ done
221
+
222
+ # ── scratch dir setup ───────────────────────────────────────────────────────
223
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
224
+ # Root the scratch dir at the git toplevel, not the (arbitrary) cwd — so a run
225
+ # from a subdir still confines and cleans up under <repo-root>/.mass-update.
226
+ if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
227
+ echo "error: not inside a git repository" >&2
228
+ echo "hint: run mass-update from within the rollout-cli repo" >&2
229
+ exit 1
230
+ fi
231
+ SCRATCH_DIR="$REPO_ROOT/.mass-update"
232
+ CLONES_DIR="$SCRATCH_DIR/clones"
233
+
234
+ # Track which scratch dirs to clean up
235
+ CLEANUP_DIRS=()
236
+ cleanup() {
237
+ for d in "${CLEANUP_DIRS[@]}"; do
238
+ if [[ -d "$d" ]]; then
239
+ rm -rf "$d"
240
+ fi
241
+ done
242
+ # Also remove the .mass-update dir itself if empty or if not keep-clone
243
+ if [[ "$KEEP_CLONE" -eq 0 ]]; then
244
+ rm -rf "$SCRATCH_DIR"
245
+ fi
246
+ }
247
+
248
+ if [[ "$KEEP_CLONE" -eq 0 ]]; then
249
+ trap cleanup EXIT
250
+ fi
251
+
252
+ # ── apply ops to a clone ────────────────────────────────────────────────────
253
+ apply_file_ops() {
254
+ local clone_dir="$1"
255
+ local i dest
256
+
257
+ for ((i=0; i<${#FILE_DESTS[@]}; i++)); do
258
+ dest="${FILE_DESTS[$i]}"
259
+ # Ensure parent dir exists
260
+ mkdir -p "$(dirname "$clone_dir/$dest")"
261
+ if [[ "${FILE_KINDS[$i]}" == "from" ]]; then
262
+ # Byte-verbatim copy — `$(cat)` would strip trailing newlines.
263
+ cp -- "${FILE_SRCS[$i]}" "$clone_dir/$dest"
264
+ else
265
+ printf '%s' "${FILE_CONTENTS[$i]}" > "$clone_dir/$dest"
266
+ fi
267
+ done
268
+ }
269
+
270
+ apply_edit_ops() {
271
+ local clone_dir="$1"
272
+ local edit file_part find_part replace_part
273
+
274
+ for edit in "${EDITS[@]}"; do
275
+ # Parse 'FILE: FIND => REPLACE'
276
+ # Split on first ': ' to get file and the rest
277
+ file_part="${edit%%: *}"
278
+ rest="${edit#*: }"
279
+ # Split on ' => ' to get find and replace
280
+ find_part="${rest%% => *}"
281
+ replace_part="${rest#* => }"
282
+
283
+ local target_file="$clone_dir/$file_part"
284
+ if [[ ! -f "$target_file" ]]; then
285
+ echo "error: file '$file_part' not found in clone" >&2
286
+ return 1
287
+ fi
288
+
289
+ # Use Python for unique-replace check
290
+ python3 -c "
291
+ import sys
292
+ content = open(sys.argv[1]).read()
293
+ find = sys.argv[2]
294
+ replace = sys.argv[3]
295
+ count = content.count(find)
296
+ if count != 1:
297
+ print(f\"error: '{find}' occurs {count} times (need exactly 1)\", file=sys.stderr)
298
+ sys.exit(1)
299
+ content = content.replace(find, replace)
300
+ open(sys.argv[1], 'w').write(content)
301
+ " "$target_file" "$find_part" "$replace_part" || return 1
302
+ done
303
+ }
304
+
305
+ # ── process each repo ───────────────────────────────────────────────────────
306
+ overall_rc=0
307
+
308
+ for raw in "${TARGETS[@]}"; do
309
+ target="${raw#"${raw%%[![:space:]]*}"}"
310
+ target="${target%"${target##*[![:space:]]}"}"
311
+ [[ -n "$target" ]] || continue
312
+
313
+ owner="${target%%/*}"
314
+ repo="${target#*/}"
315
+ clone_name="${owner}-${repo}"
316
+ clone_dir="$CLONES_DIR/$clone_name"
317
+
318
+ # Check auth before cloning
319
+ if ! gh auth status >/dev/null 2>&1; then
320
+ echo "error: gh auth status failed" >&2
321
+ echo "hint: run 'gh auth login' to authenticate with GitHub" >&2
322
+ exit 1
323
+ fi
324
+
325
+ # Create scratch dirs
326
+ mkdir -p "$clone_dir"
327
+ CLEANUP_DIRS+=("$clone_dir")
328
+
329
+ # Clone the repo; fail this target if it didn't produce a git work tree
330
+ # (don't let a failed clone silently fall through to confusing op errors).
331
+ if ! gh repo clone "$target" "$clone_dir" 2>/dev/null \
332
+ || ! git -C "$clone_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
333
+ echo "error: failed to clone $target" >&2
334
+ echo "hint: check the slug, your gh auth, and network access" >&2
335
+ overall_rc=1
336
+ continue
337
+ fi
338
+
339
+ # Create work branch
340
+ git -C "$clone_dir" checkout -b "mass-update/$clone_name" 2>/dev/null || \
341
+ git -C "$clone_dir" switch -c "mass-update/$clone_name" 2>/dev/null || true
342
+
343
+ # Apply file ops
344
+ apply_file_ops "$clone_dir" || { overall_rc=1; continue; }
345
+
346
+ # Apply edit ops
347
+ apply_edit_ops "$clone_dir" || { overall_rc=1; continue; }
348
+
349
+ if [[ "$DO_PR" -eq 1 ]]; then
350
+ # Issues are filed ONLY via communicate's post-issue.sh — no direct
351
+ # `agtag` fallback (that would bypass the /communicate channel).
352
+ post_issue_script="$SCRIPT_DIR/../../communicate/scripts/post-issue.sh"
353
+ if [[ ! -f "$post_issue_script" ]]; then
354
+ echo "error: communicate post-issue.sh not found — cannot file tracking issue" >&2
355
+ echo "hint: issues must be filed via /communicate (post-issue.sh); install the communicate skill" >&2
356
+ overall_rc=1
357
+ continue
358
+ fi
359
+
360
+ # Stage + commit (fail this target loudly on error, never mask it).
361
+ git -C "$clone_dir" add -A
362
+ if ! git -C "$clone_dir" commit -m "mass-update: deterministic update"; then
363
+ echo "error: commit failed for $target" >&2
364
+ overall_rc=1
365
+ continue
366
+ fi
367
+
368
+ # Push work branch — a failed push must not be swallowed.
369
+ if ! git -C "$clone_dir" push origin "mass-update/$clone_name"; then
370
+ echo "error: push failed for $target" >&2
371
+ overall_rc=1
372
+ continue
373
+ fi
374
+
375
+ # Create PR — capture failure rather than printing an empty URL.
376
+ if ! pr_url="$(gh pr create \
377
+ --repo "$target" \
378
+ --title "mass-update: deterministic update" \
379
+ --body "Automated update via mass-update skill." \
380
+ --head "mass-update/$clone_name")" || [[ -z "$pr_url" ]]; then
381
+ echo "error: gh pr create failed for $target" >&2
382
+ overall_rc=1
383
+ continue
384
+ fi
385
+ echo "PR_URL: $pr_url"
386
+
387
+ # File the companion tracking issue. Keep the body file INSIDE the
388
+ # scratch dir — never $TMPDIR — so no write lands outside the repo.
389
+ tmp_body="$(mktemp -p "$SCRATCH_DIR")"
390
+ printf 'PR: %s\n' "$pr_url" > "$tmp_body"
391
+ if ! issue_url="$(bash "$post_issue_script" \
392
+ --repo "$target" \
393
+ --title "mass-update: deterministic update" \
394
+ --body-file "$tmp_body")" || [[ -z "$issue_url" ]]; then
395
+ echo "error: filing tracking issue failed for $target" >&2
396
+ rm -f "$tmp_body"
397
+ overall_rc=1
398
+ continue
399
+ fi
400
+ rm -f "$tmp_body"
401
+ echo "ISSUE_URL: $issue_url"
402
+ else
403
+ # Preview mode: stage and show diff
404
+ git -C "$clone_dir" add -A
405
+ echo "=== Diff for $target ==="
406
+ git -C "$clone_dir" diff --cached
407
+ fi
408
+ done
409
+
410
+ exit "$overall_rc"