code-mower 0.5.0b5__py3-none-any.whl

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 (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
code_mower/init.py ADDED
@@ -0,0 +1,888 @@
1
+ #!/usr/bin/env python3
2
+ """Render a non-mutating Code Mower init plan for a setup profile."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import shutil
10
+ import shlex
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Mapping
15
+
16
+ if __package__ in {None, ""}:
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
+
19
+ if __package__ in {None, "", "tools"}:
20
+ from tools import code_mower_secrets
21
+ from tools.code_mower_config import (
22
+ ConfigError,
23
+ RenderedPlan,
24
+ _format_issues,
25
+ _labels_for,
26
+ load_config,
27
+ required_secret_entries_for_lane,
28
+ validate_config,
29
+ )
30
+ else: # pragma: no cover - exercised after package extraction.
31
+ from . import secrets as code_mower_secrets
32
+ from .config import (
33
+ ConfigError,
34
+ RenderedPlan,
35
+ _format_issues,
36
+ _labels_for,
37
+ load_config,
38
+ required_secret_entries_for_lane,
39
+ validate_config,
40
+ )
41
+
42
+
43
+ WORKFLOW_TARGETS_BY_DRIVER = {
44
+ "api_model": (
45
+ ".github/workflows/{lane_stem}.yml",
46
+ ".github/workflows/{lane_stem}-labeler.yml",
47
+ ),
48
+ "hosted_bridge": (
49
+ ".github/workflows/{lane_stem}-bridge.yml",
50
+ ".github/workflows/{lane_stem}-labeler.yml",
51
+ ),
52
+ "local_cli": (".github/workflows/{lane_stem}-labeler.yml",),
53
+ "manual": (),
54
+ "saas_event": (".github/workflows/{lane_stem}-labeler.yml",),
55
+ }
56
+
57
+ DEFAULT_APPLY_OUTPUT_DIR = ".code-mower.generated"
58
+ APPLY_MANIFEST_FILENAME = "code-mower-init-plan.json"
59
+ APPLY_SUMMARY_FILES = ("labels.txt", "required-secrets.txt", "smoke-tests.sh")
60
+ REFERENCE_PYTHON = ".code-mower-venv/bin/python"
61
+ GEMINI_AUTH_FILE_ENV = "GEMINI_API_KEY_FILE"
62
+ GEMINI_AUTH_ENV_NAMES = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
63
+ STARTER_DATA_FILES = (
64
+ (
65
+ "calibration-corpus.json",
66
+ "tools/calibration_corpus.example.json",
67
+ "templates/calibration-corpus.example.json",
68
+ "starter-calibration-corpus",
69
+ ),
70
+ (
71
+ "context-packs.json",
72
+ "tools/context_packs.example.json",
73
+ "templates/context-packs.example.json",
74
+ "starter-context-packs",
75
+ ),
76
+ (
77
+ "reviewer-spend.json",
78
+ "tools/reviewer_spend.example.json",
79
+ "templates/reviewer-spend.example.json",
80
+ "starter-reviewer-spend",
81
+ ),
82
+ (
83
+ "reviewer-value-report.example.md",
84
+ "tools/reviewer_value_report.example.md",
85
+ "templates/reviewer-value-report.example.md",
86
+ "starter-reviewer-value-report",
87
+ ),
88
+ )
89
+
90
+ PRODUCT_SUPPORT_FILES = (
91
+ (
92
+ "tools/code_mower",
93
+ "templates/product-support/code_mower",
94
+ "product-support-wrapper",
95
+ "0755",
96
+ ),
97
+ (
98
+ "tools/code_mower_standalone_shadow.sh",
99
+ "templates/product-support/code_mower_standalone_shadow.sh",
100
+ "product-support-wrapper",
101
+ "0755",
102
+ ),
103
+ (
104
+ "tools/code_mower_standalone_pin.env",
105
+ "templates/product-support/code_mower_standalone_pin.env",
106
+ "product-support-config",
107
+ "0644",
108
+ ),
109
+ (
110
+ "tools/run_codex_audit_pr.sh",
111
+ "templates/product-support/run_codex_audit_pr.sh",
112
+ "product-support-wrapper",
113
+ "0755",
114
+ ),
115
+ (
116
+ "tools/run_claude_audit_pr.sh",
117
+ "templates/product-support/run_claude_audit_pr.sh",
118
+ "product-support-wrapper",
119
+ "0755",
120
+ ),
121
+ (
122
+ "tools/safe_gh_comment.py",
123
+ "templates/product-support/safe_gh_comment.py",
124
+ "product-support-helper",
125
+ "0755",
126
+ ),
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class InitProfile:
132
+ profile_id: str
133
+ description: str
134
+ lanes: tuple[str, ...]
135
+
136
+
137
+ def _profile(config: Mapping[str, Any], profile_id: str) -> InitProfile:
138
+ profiles = config.get("profiles")
139
+ if not isinstance(profiles, Mapping) or profile_id not in profiles:
140
+ available = ", ".join(sorted(profiles)) if isinstance(profiles, Mapping) else "none"
141
+ raise ConfigError(f"unknown profile {profile_id!r}; available profiles: {available}")
142
+ profile = profiles[profile_id]
143
+ if not isinstance(profile, Mapping):
144
+ raise ConfigError(f"profile {profile_id!r} must be a mapping")
145
+ lanes = profile.get("lanes", [])
146
+ if not isinstance(lanes, list):
147
+ raise ConfigError(f"profile {profile_id!r} lanes must be a list")
148
+ return InitProfile(
149
+ profile_id=profile_id,
150
+ description=str(profile.get("description", "")),
151
+ lanes=tuple(str(lane) for lane in lanes),
152
+ )
153
+
154
+
155
+ def _workflow_targets(lane_id: str, lane: Mapping[str, Any]) -> tuple[str, ...]:
156
+ templates = WORKFLOW_TARGETS_BY_DRIVER.get(str(lane.get("driver")), ())
157
+ normalized = lane_id.replace("_", "-")
158
+ lane_type = str(lane.get("type"))
159
+ suffix = "-review" if lane_type == "review" else "-audit"
160
+ lane_stem = normalized if normalized.endswith(suffix) else f"{normalized}{suffix}"
161
+ return tuple(template.format(lane_stem=lane_stem) for template in templates)
162
+
163
+
164
+ def _trailer_lane_name(lane_id: str, lane: Mapping[str, Any]) -> str:
165
+ return str(lane.get("trailer_lane") or lane.get("lane_config") or lane_id)
166
+
167
+
168
+ def _lane_module_name(lane_id: str) -> str:
169
+ return lane_id.replace("-", "_")
170
+
171
+
172
+ def _running_as_package() -> bool:
173
+ return bool(__package__ and __package__ != "tools")
174
+
175
+
176
+ def _default_package_command() -> str:
177
+ command = sys.argv[0] or "code-mower"
178
+ name = Path(command).name
179
+ if name.endswith(".py"):
180
+ return f"{shlex.quote(sys.executable)} -m code_mower.cli"
181
+ python_suffix = name.removeprefix("python")
182
+ is_python_launcher = name == "python" or (
183
+ name.startswith("python")
184
+ and bool(python_suffix)
185
+ and python_suffix.replace(".", "").isdigit()
186
+ )
187
+ if is_python_launcher or name in {
188
+ "",
189
+ "-c",
190
+ "__main__.py",
191
+ "pytest",
192
+ "py.test",
193
+ }:
194
+ command = "code-mower"
195
+ return shlex.quote(command)
196
+
197
+
198
+ def _resolve_config_path(config_arg: str) -> Path:
199
+ path = Path(config_arg)
200
+ if path.is_file() or config_arg != "code-mower.example.yml":
201
+ return path
202
+
203
+ script_path = Path(__file__).resolve()
204
+ candidates = []
205
+ if _running_as_package():
206
+ candidates.append(script_path.parent / "templates" / "code-mower.example.yml")
207
+ else:
208
+ candidates.append(script_path.parents[1] / "code-mower.example.yml")
209
+
210
+ for candidate in candidates:
211
+ if candidate.is_file():
212
+ return candidate
213
+ return path
214
+
215
+
216
+ def _lane_smoke_tests(
217
+ lane_id: str,
218
+ lane: Mapping[str, Any],
219
+ *,
220
+ package_mode: bool,
221
+ ) -> tuple[str, ...]:
222
+ driver = lane.get("driver")
223
+ if package_mode and driver in {"local_cli", "hosted_bridge", "api_model", "manual", "saas_event"}:
224
+ return ()
225
+ if driver in {"local_cli", "hosted_bridge", "api_model"}:
226
+ trailer_lane = json.dumps(_trailer_lane_name(lane_id, lane))
227
+ code = f"from tools.lane_configs import load_lane_config; load_lane_config({trailer_lane})"
228
+ return (
229
+ f"{REFERENCE_PYTHON} -c {shlex.quote(code)}",
230
+ )
231
+ if driver == "saas_event":
232
+ adapter = json.dumps(str(lane.get("adapter")))
233
+ code = f"from tools.adapters import load_adapter; load_adapter({adapter})"
234
+ return (
235
+ f"{REFERENCE_PYTHON} -c {shlex.quote(code)}",
236
+ )
237
+ if driver == "manual":
238
+ return ("bash -n tools/post_review.sh",)
239
+ return ()
240
+
241
+
242
+ def _lane_warnings(
243
+ lane_id: str,
244
+ lane: Mapping[str, Any],
245
+ *,
246
+ package_mode: bool,
247
+ ) -> tuple[str, ...]:
248
+ warnings: list[str] = []
249
+ if lane.get("spend_policy") == "paid":
250
+ warnings.append(f"{lane_id}: paid lane; init dry-run will not trigger a review")
251
+ if lane.get("enabled_by_default") is False:
252
+ warnings.append(f"{lane_id}: opt-in lane selected by profile")
253
+ if lane.get("trigger_policy") == "manual":
254
+ warnings.append(f"{lane_id}: manual trigger policy; installer must not auto-dispatch")
255
+ if package_mode:
256
+ driver = lane.get("driver")
257
+ if driver in {"local_cli", "hosted_bridge", "api_model"}:
258
+ warnings.append(
259
+ f"{lane_id}: lane-config smoke deferred until package-relative lane configs are extracted"
260
+ )
261
+ elif driver == "saas_event":
262
+ warnings.append(
263
+ f"{lane_id}: adapter smoke deferred until package-relative adapters are extracted"
264
+ )
265
+ elif driver == "manual":
266
+ warnings.append(
267
+ f"{lane_id}: manual review script smoke deferred until repo-local review scripts are installed"
268
+ )
269
+ return tuple(warnings)
270
+
271
+
272
+ def _repo_root() -> Path:
273
+ if __package__ and __package__ != "tools":
274
+ return Path.cwd()
275
+ return Path(__file__).resolve().parents[1]
276
+
277
+
278
+ def default_auth_config_dir(home_dir: Path | None = None) -> Path:
279
+ return (home_dir or Path.home()) / ".config" / "code-mower"
280
+
281
+
282
+ def default_gemini_auth_path(home_dir: Path | None = None) -> Path:
283
+ return default_auth_config_dir(home_dir) / "gemini.env"
284
+
285
+
286
+ def _shell_export_line(name: str, value: str) -> str:
287
+ return f"export {name}={shlex.quote(value)}"
288
+
289
+
290
+ def _parse_gemini_secret_source(text: str) -> str:
291
+ return code_mower_secrets.parse_secret_file_text(
292
+ text,
293
+ supported_env_names=set(GEMINI_AUTH_ENV_NAMES),
294
+ ).value
295
+
296
+
297
+ def write_gemini_auth_file(
298
+ secret_value: str,
299
+ path: Path | None = None,
300
+ *,
301
+ force: bool = False,
302
+ ) -> dict[str, Any]:
303
+ value = _parse_gemini_secret_source(secret_value)
304
+ if not value:
305
+ raise ConfigError("Gemini key source was empty or not a supported Gemini key assignment")
306
+ destination = (path or default_gemini_auth_path()).expanduser()
307
+ if destination.is_symlink():
308
+ raise ConfigError(f"{destination} is a symlink; refusing to write secrets through it")
309
+ if destination.exists() and not destination.is_file():
310
+ raise ConfigError(f"{destination} is not a regular file; refusing to write secrets")
311
+ if destination.exists() and not force:
312
+ raise ConfigError(f"{destination} already exists; pass --force to overwrite it")
313
+ parent_existed = destination.parent.exists()
314
+ destination.parent.mkdir(parents=True, exist_ok=True)
315
+ if path is None or not parent_existed:
316
+ destination.parent.chmod(0o700)
317
+ if destination.exists():
318
+ destination.chmod(0o600)
319
+ flags = os.O_WRONLY | os.O_CREAT
320
+ flags |= os.O_TRUNC if force else os.O_EXCL
321
+ if hasattr(os, "O_NOFOLLOW"):
322
+ flags |= os.O_NOFOLLOW
323
+ try:
324
+ fd = os.open(destination, flags, 0o600)
325
+ except FileExistsError as exc:
326
+ raise ConfigError(f"{destination} already exists; pass --force to overwrite it") from exc
327
+ try:
328
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
329
+ fd = -1
330
+ handle.write(value + "\n")
331
+ finally:
332
+ if fd != -1:
333
+ os.close(fd)
334
+ destination.chmod(0o600)
335
+ return {
336
+ "mode": "auth",
337
+ "provider": "gemini",
338
+ "path": str(destination),
339
+ "file_env": GEMINI_AUTH_FILE_ENV,
340
+ "shell_export": _shell_export_line(GEMINI_AUTH_FILE_ENV, str(destination)),
341
+ }
342
+
343
+
344
+ def _render_gemini_auth_instructions(path: Path | None = None) -> str:
345
+ destination = (path or default_gemini_auth_path()).expanduser()
346
+ return "\n".join(
347
+ [
348
+ "Code Mower Gemini auth setup",
349
+ f"Credential file: {destination}",
350
+ "",
351
+ "Write the key without putting it in shell history:",
352
+ f" printf '%s\\n' \"$GEMINI_API_KEY\" | code-mower init auth gemini --from-stdin --path {shlex.quote(str(destination))}",
353
+ "",
354
+ "Then make the file discoverable:",
355
+ f" {_shell_export_line(GEMINI_AUTH_FILE_ENV, str(destination))}",
356
+ "",
357
+ ]
358
+ )
359
+
360
+
361
+ def _auth_main(argv: list[str]) -> int:
362
+ parser = argparse.ArgumentParser(prog="code-mower init auth")
363
+ subparsers = parser.add_subparsers(dest="provider", required=True)
364
+ gemini = subparsers.add_parser("gemini")
365
+ gemini.add_argument(
366
+ "--from-stdin",
367
+ action="store_true",
368
+ help="read the Gemini key or GEMINI_API_KEY assignment from stdin",
369
+ )
370
+ gemini.add_argument(
371
+ "--from-env",
372
+ nargs="?",
373
+ const="GEMINI_API_KEY",
374
+ help="read the key from an environment variable, defaulting to GEMINI_API_KEY",
375
+ )
376
+ gemini.add_argument(
377
+ "--path",
378
+ type=Path,
379
+ default=None,
380
+ help="credential file path, defaulting to ~/.config/code-mower/gemini.env",
381
+ )
382
+ gemini.add_argument("--force", action="store_true", help="overwrite an existing file")
383
+ gemini.add_argument("--print-shell", action="store_true", help="print shell export setup")
384
+ gemini.add_argument("--json", action="store_true")
385
+ args = parser.parse_args(argv)
386
+
387
+ if args.provider != "gemini": # pragma: no cover - argparse guards this.
388
+ raise AssertionError(f"unhandled auth provider: {args.provider}")
389
+ if args.from_stdin and args.from_env:
390
+ print("error: choose either --from-stdin or --from-env", file=sys.stderr)
391
+ return 1
392
+ if not args.from_stdin and not args.from_env:
393
+ text = _render_gemini_auth_instructions(args.path)
394
+ if args.json:
395
+ print(
396
+ json.dumps(
397
+ {
398
+ "mode": "auth",
399
+ "provider": "gemini",
400
+ "path": str((args.path or default_gemini_auth_path()).expanduser()),
401
+ "file_env": GEMINI_AUTH_FILE_ENV,
402
+ "created": False,
403
+ },
404
+ indent=2,
405
+ sort_keys=True,
406
+ )
407
+ )
408
+ else:
409
+ print(text, end="")
410
+ return 0
411
+
412
+ if args.from_stdin:
413
+ source_text = sys.stdin.read()
414
+ else:
415
+ env_name = str(args.from_env)
416
+ if env_name not in GEMINI_AUTH_ENV_NAMES:
417
+ print(
418
+ "error: --from-env must be GEMINI_API_KEY or GOOGLE_API_KEY",
419
+ file=sys.stderr,
420
+ )
421
+ return 1
422
+ source_text = os.environ.get(env_name, "")
423
+ if not source_text:
424
+ print(f"error: {env_name} is not set", file=sys.stderr)
425
+ return 1
426
+
427
+ try:
428
+ result = write_gemini_auth_file(source_text, args.path, force=args.force)
429
+ except ConfigError as exc:
430
+ print(f"error: {exc}", file=sys.stderr)
431
+ return 1
432
+
433
+ if args.json:
434
+ print(json.dumps({**result, "created": True}, indent=2, sort_keys=True))
435
+ else:
436
+ print(f"Code Mower Gemini auth file written: {result['path']}")
437
+ if args.print_shell:
438
+ print(result["shell_export"])
439
+ else:
440
+ print(f"Set {GEMINI_AUTH_FILE_ENV} for future shells:")
441
+ print(f" {result['shell_export']}")
442
+ return 0
443
+
444
+
445
+ def _safe_output_path(output_dir: Path, relative_path: str) -> Path:
446
+ path = Path(relative_path)
447
+ if path.is_absolute() or ".." in path.parts or not path.parts:
448
+ raise ConfigError(f"unsafe generated path: {relative_path}")
449
+ destination = output_dir.joinpath(path)
450
+ try:
451
+ destination.resolve().relative_to(output_dir.resolve())
452
+ except ValueError as exc:
453
+ raise ConfigError(f"generated path escapes output directory: {relative_path}") from exc
454
+ return destination
455
+
456
+
457
+ def _placeholder_text(path: str, source: str) -> str:
458
+ return (
459
+ f"# Code Mower generated placeholder for {path}\n"
460
+ f"# Source template: {source}\n"
461
+ "# This reference apply mode writes placeholders only when the source\n"
462
+ "# file is not present in the checkout. The standalone package will\n"
463
+ "# render this file from bundled templates.\n"
464
+ )
465
+
466
+
467
+ def _render_stale_workflow_template(text: str, *, lane: str) -> str:
468
+ """Render the shared stale-label workflow without requiring Jinja at runtime."""
469
+
470
+ return (
471
+ text.replace("{% raw %}", "")
472
+ .replace("{% endraw %}", "")
473
+ .replace('default: "devin"', f'default: "{lane}"')
474
+ .replace("github.event.inputs.lane || 'devin'", f"github.event.inputs.lane || '{lane}'")
475
+ )
476
+
477
+
478
+ def _copy_source_candidates(source_root: Path, entry: Mapping[str, Any], path: str) -> tuple[Path, ...]:
479
+ candidates: list[Path] = []
480
+ package_copy_from = entry.get("package_copy_from")
481
+ if entry.get("package_copy_first") and package_copy_from:
482
+ candidates.append(Path(__file__).resolve().parent / str(package_copy_from))
483
+ copy_from = str(entry.get("copy_from", path))
484
+ candidates.append(source_root / copy_from)
485
+ if package_copy_from and not entry.get("package_copy_first"):
486
+ candidates.append(Path(__file__).resolve().parent / str(package_copy_from))
487
+ return tuple(candidates)
488
+
489
+
490
+ def _previous_apply_paths(output_dir: Path) -> list[Path]:
491
+ manifest_path = output_dir / APPLY_MANIFEST_FILENAME
492
+ if not manifest_path.exists():
493
+ return []
494
+ try:
495
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
496
+ except (OSError, json.JSONDecodeError):
497
+ return []
498
+
499
+ paths = [manifest_path, *(output_dir / filename for filename in APPLY_SUMMARY_FILES)]
500
+ generated_files = manifest.get("generated_files", [])
501
+ if isinstance(generated_files, list):
502
+ for entry in generated_files:
503
+ if not isinstance(entry, dict):
504
+ continue
505
+ try:
506
+ paths.append(_safe_output_path(output_dir, str(entry.get("path", ""))))
507
+ except ConfigError:
508
+ continue
509
+ return paths
510
+
511
+
512
+ def _prune_previous_apply(output_dir: Path) -> None:
513
+ previous_paths = _previous_apply_paths(output_dir)
514
+ for path in sorted(previous_paths, key=lambda item: len(item.parts), reverse=True):
515
+ if path.is_file() or path.is_symlink():
516
+ path.unlink()
517
+
518
+ generated_parents: set[Path] = set()
519
+ for path in previous_paths:
520
+ parent = path.parent
521
+ while parent != output_dir and output_dir in parent.parents:
522
+ generated_parents.add(parent)
523
+ parent = parent.parent
524
+
525
+ for directory in sorted(generated_parents, key=lambda item: len(item.parts), reverse=True):
526
+ try:
527
+ directory.rmdir()
528
+ except OSError:
529
+ pass
530
+
531
+
532
+ def apply_init_plan(
533
+ plan: RenderedPlan,
534
+ output_dir: Path,
535
+ *,
536
+ source_root: Path | None = None,
537
+ ) -> dict[str, Any]:
538
+ source_root = (source_root or _repo_root()).resolve()
539
+ resolved_output_dir = output_dir.resolve()
540
+ if resolved_output_dir == source_root:
541
+ raise ConfigError(
542
+ "refusing to write generated output into the source root; "
543
+ "choose a dedicated --output-dir such as .code-mower.generated"
544
+ )
545
+
546
+ generated_destinations: list[tuple[dict[str, Any], str, Path]] = []
547
+ for entry in plan.data["generated_files"]:
548
+ path = str(entry["path"])
549
+ generated_destinations.append((entry, path, _safe_output_path(output_dir, path)))
550
+
551
+ output_dir.mkdir(parents=True, exist_ok=True)
552
+ _prune_previous_apply(output_dir)
553
+ written_files: list[str] = []
554
+ placeholder_files: list[str] = []
555
+
556
+ manifest_path = output_dir / APPLY_MANIFEST_FILENAME
557
+ manifest_path.write_text(json.dumps(plan.data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
558
+ written_files.append(str(manifest_path))
559
+
560
+ summary_files = {
561
+ "labels.txt": "\n".join(plan.data["labels"]) + "\n",
562
+ "required-secrets.txt": "\n".join(plan.data["required_secrets"]) + "\n",
563
+ "smoke-tests.sh": "#!/usr/bin/env bash\nset -euo pipefail\n\n"
564
+ + "\n".join(plan.data["smoke_tests"])
565
+ + "\n",
566
+ }
567
+ for filename, text in summary_files.items():
568
+ destination = output_dir / filename
569
+ destination.write_text(text, encoding="utf-8")
570
+ if filename.endswith(".sh"):
571
+ destination.chmod(0o755)
572
+ written_files.append(str(destination))
573
+
574
+ for entry, path, destination in generated_destinations:
575
+ destination.parent.mkdir(parents=True, exist_ok=True)
576
+ source = next(
577
+ (candidate for candidate in _copy_source_candidates(source_root, entry, path) if candidate.is_file()),
578
+ None,
579
+ )
580
+ if source is not None:
581
+ if entry.get("source") == "shared-stale-label-template":
582
+ destination.write_text(
583
+ _render_stale_workflow_template(
584
+ source.read_text(encoding="utf-8"),
585
+ lane=str(entry.get("stale_lane") or "devin"),
586
+ ),
587
+ encoding="utf-8",
588
+ )
589
+ else:
590
+ shutil.copyfile(source, destination)
591
+ else:
592
+ destination.write_text(_placeholder_text(path, str(entry["source"])), encoding="utf-8")
593
+ placeholder_files.append(str(destination))
594
+ if entry.get("mode") == "0755":
595
+ destination.chmod(0o755)
596
+ written_files.append(str(destination))
597
+
598
+ return {
599
+ "mode": "apply",
600
+ "output_dir": str(output_dir),
601
+ "written_files": written_files,
602
+ "placeholder_files": placeholder_files,
603
+ }
604
+
605
+
606
+ def render_init_plan(
607
+ config: Mapping[str, Any],
608
+ profile_id: str = "recommended",
609
+ config_path: str = "code-mower.example.yml",
610
+ *,
611
+ package_mode: bool | None = None,
612
+ package_command: str | None = None,
613
+ ) -> RenderedPlan:
614
+ issues = validate_config(config)
615
+ if issues:
616
+ raise ConfigError(f"invalid Code Mower config:\n{_format_issues(issues)}")
617
+
618
+ profile = _profile(config, profile_id)
619
+ lanes: Mapping[str, Mapping[str, Any]] = config["lanes"]
620
+ selected_lanes = {lane_id: lanes[lane_id] for lane_id in profile.lanes}
621
+
622
+ labels: list[str] = []
623
+ workflows: list[dict[str, str]] = []
624
+ generated_files: list[dict[str, str]] = []
625
+ workflow_targets: set[str] = set()
626
+ generated_paths: set[str] = set()
627
+ required_secrets: set[str] = set()
628
+ quoted_config_path = shlex.quote(config_path)
629
+ quoted_profile_id = shlex.quote(profile.profile_id)
630
+ if package_mode is None:
631
+ package_mode = _running_as_package()
632
+ smoke_command_prefix = (
633
+ shlex.quote(package_command)
634
+ if package_mode and package_command
635
+ else _default_package_command()
636
+ if package_mode
637
+ else f"{REFERENCE_PYTHON} tools/code_mower"
638
+ )
639
+ smoke_tests: list[str] = [
640
+ (
641
+ f"{smoke_command_prefix} config validate {quoted_config_path}"
642
+ if package_mode
643
+ else f"{smoke_command_prefix}_config.py {quoted_config_path} --validate-only"
644
+ ),
645
+ (
646
+ f"{smoke_command_prefix} init {quoted_config_path} --profile {quoted_profile_id} --dry-run --json"
647
+ if package_mode
648
+ else f"{smoke_command_prefix}_init.py {quoted_config_path} --profile {quoted_profile_id} --dry-run --json"
649
+ ),
650
+ ]
651
+ warnings: list[str] = []
652
+ merge_authority_lanes: list[str] = []
653
+ informational_lanes: list[str] = []
654
+
655
+ for lane_id, lane in selected_lanes.items():
656
+ lane_labels = _labels_for(lane)
657
+ labels.extend(str(lane_labels[key]) for key in ("needs", "done", "blocked"))
658
+ if lane.get("merge_authority"):
659
+ merge_authority_lanes.append(lane_id)
660
+ if lane.get("informational"):
661
+ informational_lanes.append(lane_id)
662
+ required_secrets.update(required_secret_entries_for_lane(lane))
663
+ for target in _workflow_targets(lane_id, lane):
664
+ if target in workflow_targets:
665
+ warnings.append(f"{lane_id}: workflow target {target} collides with another lane")
666
+ continue
667
+ workflow_targets.add(target)
668
+ workflows.append(
669
+ {
670
+ "lane": lane_id,
671
+ "driver": str(lane["driver"]),
672
+ "target": target,
673
+ }
674
+ )
675
+ if target not in generated_paths:
676
+ generated_paths.add(target)
677
+ generated_files.append({"path": target, "source": "workflow-template"})
678
+ if lane.get("driver") in {"local_cli", "hosted_bridge", "api_model"}:
679
+ trailer_lane = _trailer_lane_name(lane_id, lane)
680
+ trailer_module = _lane_module_name(trailer_lane)
681
+ path = f"tools/lane_configs/{trailer_module}.py"
682
+ if path in generated_paths:
683
+ warnings.append(f"{lane_id}: generated file {path} collides with another lane")
684
+ else:
685
+ generated_paths.add(path)
686
+ generated_files.append(
687
+ {
688
+ "path": path,
689
+ "source": "lane-config-template",
690
+ }
691
+ )
692
+ smoke_tests.extend(_lane_smoke_tests(lane_id, lane, package_mode=package_mode))
693
+ warnings.extend(_lane_warnings(lane_id, lane, package_mode=package_mode))
694
+
695
+ cleanup_path = ".github/workflows/audit-label-cleanup.yml"
696
+ if cleanup_path not in generated_paths:
697
+ required_secrets.add("AUDIT_LABEL_CLEANUP_TOKEN")
698
+ generated_paths.add(cleanup_path)
699
+ generated_files.append(
700
+ {
701
+ "path": cleanup_path,
702
+ "source": "shared-cleanup-template",
703
+ }
704
+ )
705
+ for lane_id, lane in selected_lanes.items():
706
+ hygiene = lane.get("review_hygiene")
707
+ if not isinstance(hygiene, Mapping) or not hygiene:
708
+ continue
709
+ stale_path = str(hygiene["workflow"])
710
+ if stale_path not in generated_paths:
711
+ generated_paths.add(stale_path)
712
+ generated_files.append(
713
+ {
714
+ "path": stale_path,
715
+ "source": "shared-stale-label-template",
716
+ "copy_from": "templates/workflows/review-clear-stale.yml.j2",
717
+ "package_copy_from": "templates/workflows/review-clear-stale.yml.j2",
718
+ "stale_lane": _trailer_lane_name(lane_id, lane),
719
+ }
720
+ )
721
+ for target, copy_from, package_copy_from, source_name in STARTER_DATA_FILES:
722
+ if target in generated_paths:
723
+ warnings.append(f"starter data target {target} collides with another generated file")
724
+ continue
725
+ generated_paths.add(target)
726
+ generated_files.append(
727
+ {
728
+ "path": target,
729
+ "source": source_name,
730
+ "copy_from": copy_from,
731
+ "package_copy_from": package_copy_from,
732
+ }
733
+ )
734
+ for target, package_copy_from, source_name, mode in PRODUCT_SUPPORT_FILES:
735
+ if target in generated_paths:
736
+ warnings.append(f"product support target {target} collides with another generated file")
737
+ continue
738
+ generated_paths.add(target)
739
+ generated_files.append(
740
+ {
741
+ "path": target,
742
+ "source": source_name,
743
+ "package_copy_from": package_copy_from,
744
+ "package_copy_first": True,
745
+ "mode": mode,
746
+ }
747
+ )
748
+
749
+ if not merge_authority_lanes:
750
+ warnings.append(
751
+ f"{profile.profile_id}: profile has no merge-authority lanes; keep informational only"
752
+ )
753
+
754
+ data = {
755
+ "mode": "dry-run",
756
+ "profile": {
757
+ "id": profile.profile_id,
758
+ "description": profile.description,
759
+ "lanes": list(profile.lanes),
760
+ },
761
+ "labels": sorted(set(labels)),
762
+ "workflows": workflows,
763
+ "generated_files": generated_files,
764
+ "required_secrets": sorted(required_secrets),
765
+ "merge_authority_lanes": merge_authority_lanes,
766
+ "informational_lanes": informational_lanes,
767
+ "smoke_tests": smoke_tests,
768
+ "warnings": warnings,
769
+ }
770
+
771
+ lines = [
772
+ "Code Mower init dry-run",
773
+ f"Profile: {profile.profile_id}",
774
+ f"Description: {profile.description}",
775
+ "",
776
+ "Selected lanes:",
777
+ ]
778
+ for lane_id, lane in selected_lanes.items():
779
+ if lane.get("merge_authority"):
780
+ role = "merge-authority"
781
+ elif lane.get("informational"):
782
+ role = "informational"
783
+ else:
784
+ role = "standard"
785
+ lines.append(f"- {lane_id}: {lane['driver']} / {lane['provider']} ({role})")
786
+
787
+ lines.extend(["", "Labels to ensure:"])
788
+ lines.extend(f"- {label}" for label in data["labels"])
789
+
790
+ lines.extend(["", "Workflow files to render:"])
791
+ if workflows:
792
+ lines.extend(f"- {workflow['target']} ({workflow['lane']})" for workflow in workflows)
793
+ else:
794
+ lines.append("- none")
795
+
796
+ lines.extend(["", "Generated file manifest:"])
797
+ lines.extend(
798
+ f"- {entry['path']} [{entry['source']}]" for entry in data["generated_files"]
799
+ )
800
+
801
+ lines.extend(["", "Required secrets/PAT fallbacks:"])
802
+ if data["required_secrets"]:
803
+ lines.extend(f"- {secret}" for secret in data["required_secrets"])
804
+ else:
805
+ lines.append("- none beyond GITHUB_TOKEN")
806
+
807
+ lines.extend(["", "Smoke tests after render:"])
808
+ lines.extend(f"- {test}" for test in smoke_tests)
809
+
810
+ if warnings:
811
+ lines.extend(["", "Warnings:"])
812
+ lines.extend(f"- {warning}" for warning in warnings)
813
+
814
+ return RenderedPlan(text="\n".join(lines) + "\n", data=data)
815
+
816
+
817
+ def main(argv: list[str] | None = None) -> int:
818
+ argv = list(sys.argv[1:] if argv is None else argv)
819
+ if argv[:1] == ["auth"]:
820
+ return _auth_main(argv[1:])
821
+
822
+ parser = argparse.ArgumentParser(description=__doc__)
823
+ parser.add_argument("config", nargs="?", default="code-mower.example.yml")
824
+ parser.add_argument("--profile", default="recommended")
825
+ parser.add_argument(
826
+ "--easy",
827
+ action="store_true",
828
+ help=(
829
+ "safe first-run alias for --profile recommended --dry-run; combine "
830
+ "with --apply to write generated output instead"
831
+ ),
832
+ )
833
+ parser.add_argument("--dry-run", action="store_true", help="render the init plan")
834
+ parser.add_argument("--apply", action="store_true", help="write generated files to --output-dir")
835
+ parser.add_argument(
836
+ "--output-dir",
837
+ default=DEFAULT_APPLY_OUTPUT_DIR,
838
+ help="safe output directory for --apply mode",
839
+ )
840
+ parser.add_argument("--json", action="store_true", help="emit dry-run plan as JSON")
841
+ args = parser.parse_args(argv)
842
+
843
+ if args.easy:
844
+ args.profile = "recommended"
845
+ if not args.dry_run and not args.apply:
846
+ args.dry_run = True
847
+ if args.apply and args.dry_run:
848
+ print("error: choose either --dry-run or --apply", file=sys.stderr)
849
+ return 1
850
+ if not args.dry_run and not args.apply:
851
+ print("error: choose --dry-run or --apply", file=sys.stderr)
852
+ return 1
853
+
854
+ try:
855
+ config_source = _resolve_config_path(args.config)
856
+ rendered_config_path = (
857
+ str(config_source) if config_source != Path(args.config) else args.config
858
+ )
859
+ plan = render_init_plan(
860
+ load_config(config_source),
861
+ profile_id=args.profile,
862
+ config_path=rendered_config_path,
863
+ )
864
+ apply_result = (
865
+ apply_init_plan(plan, Path(args.output_dir))
866
+ if args.apply
867
+ else None
868
+ )
869
+ except ConfigError as exc:
870
+ print(f"error: {exc}", file=sys.stderr)
871
+ return 1
872
+
873
+ if args.json:
874
+ print(json.dumps(apply_result or plan.data, indent=2, sort_keys=True))
875
+ elif apply_result:
876
+ print(f"Code Mower init apply wrote {len(apply_result['written_files'])} files")
877
+ print(f"Output: {apply_result['output_dir']}")
878
+ if apply_result["placeholder_files"]:
879
+ print("Placeholders:")
880
+ for path in apply_result["placeholder_files"]:
881
+ print(f"- {path}")
882
+ else:
883
+ print(plan.text, end="")
884
+ return 0
885
+
886
+
887
+ if __name__ == "__main__":
888
+ raise SystemExit(main())