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
@@ -0,0 +1,100 @@
1
+ """Provider token environment status helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .common import SUPPORTED_TOKEN_FILE_ENV_NAMES, as_sequence, code_mower_secrets
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProviderTokenStatus:
15
+ token_env: tuple[str, ...]
16
+ token_env_any: tuple[tuple[str, ...], ...]
17
+ token_file_env: tuple[str, ...]
18
+ missing: tuple[str, ...]
19
+ missing_any: tuple[tuple[str, ...], ...]
20
+
21
+ @property
22
+ def declares_tokens(self) -> bool:
23
+ return bool(self.token_env or self.token_env_any)
24
+
25
+ @property
26
+ def all_present(self) -> bool:
27
+ return not self.missing and not self.missing_any
28
+
29
+
30
+ def _token_file_value(name: str, path_text: str) -> str:
31
+ if name not in SUPPORTED_TOKEN_FILE_ENV_NAMES:
32
+ return ""
33
+ try:
34
+ result = code_mower_secrets.read_secret_file(
35
+ Path(path_text),
36
+ supported_env_names=SUPPORTED_TOKEN_FILE_ENV_NAMES,
37
+ )
38
+ except OSError:
39
+ return ""
40
+ return result.value
41
+
42
+
43
+ def _token_is_present(name: str) -> bool:
44
+ if os.environ.get(name):
45
+ return True
46
+ path_text = os.environ.get(f"{name}_FILE", "").strip()
47
+ if not path_text:
48
+ return False
49
+ return bool(_token_file_value(name, path_text))
50
+
51
+
52
+ def _token_file_env_names(
53
+ token_env: tuple[str, ...],
54
+ token_env_any: tuple[tuple[str, ...], ...],
55
+ ) -> tuple[str, ...]:
56
+ names = [
57
+ f"{name}_FILE"
58
+ for name in token_env
59
+ if name in SUPPORTED_TOKEN_FILE_ENV_NAMES and os.environ.get(f"{name}_FILE")
60
+ ]
61
+ names.extend(
62
+ f"{name}_FILE"
63
+ for group in token_env_any
64
+ for name in group
65
+ if name in SUPPORTED_TOKEN_FILE_ENV_NAMES and os.environ.get(f"{name}_FILE")
66
+ )
67
+ return tuple(sorted(set(names)))
68
+
69
+
70
+ def provider_token_status(lane: Mapping[str, Any]) -> ProviderTokenStatus:
71
+ token_env = tuple(str(item) for item in as_sequence(lane.get("token_env", [])))
72
+ token_env_any = tuple(
73
+ tuple(str(item) for item in as_sequence(group))
74
+ for group in as_sequence(lane.get("token_env_any", []))
75
+ )
76
+ review_hygiene = lane.get("review_hygiene", {})
77
+ if (
78
+ not token_env
79
+ and isinstance(review_hygiene, Mapping)
80
+ and review_hygiene.get("token_env")
81
+ ):
82
+ token_env = (str(review_hygiene["token_env"]),)
83
+
84
+ token_file_env = _token_file_env_names(token_env, token_env_any)
85
+ missing = tuple(name for name in token_env if not _token_is_present(name))
86
+ missing_any = tuple(
87
+ group
88
+ for group in token_env_any
89
+ if group and not any(_token_is_present(name) for name in group)
90
+ )
91
+ return ProviderTokenStatus(
92
+ token_env=token_env,
93
+ token_env_any=token_env_any,
94
+ token_file_env=token_file_env,
95
+ missing=missing,
96
+ missing_any=missing_any,
97
+ )
98
+
99
+
100
+ __all__ = ["ProviderTokenStatus", "provider_token_status"]
@@ -0,0 +1,162 @@
1
+ """Provider local CLI discovery and auth smoke probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from typing import Any, Mapping
8
+
9
+ from .common import (
10
+ DoctorCheck,
11
+ STATUS_PASS,
12
+ STATUS_SKIP,
13
+ STATUS_WARN,
14
+ local_cli_remediation,
15
+ )
16
+ from .provider_local_cli_commands import (
17
+ candidate_local_cli_commands,
18
+ local_cli_command,
19
+ resolved_local_cli_command,
20
+ )
21
+ from .provider_local_cli_probe_config import (
22
+ local_cli_probe_args,
23
+ local_cli_probe_env,
24
+ local_cli_probe_timeout,
25
+ )
26
+ from .provider_probe import evaluate_json_probe, local_cli_probe_remediation
27
+ from .privacy import auth_probe_output_detail
28
+
29
+
30
+ def check_local_cli(lane_id: str, lane: Mapping[str, Any]) -> DoctorCheck:
31
+ provider_config = lane.get("provider_config", {})
32
+ commands = candidate_local_cli_commands(lane)
33
+ detail: dict[str, Any] = {"commands": commands}
34
+ if isinstance(provider_config, Mapping) and provider_config.get("command"):
35
+ detail["command"] = str(provider_config["command"])
36
+ if isinstance(provider_config, Mapping) and provider_config.get("command_env"):
37
+ detail["command_env"] = str(provider_config["command_env"])
38
+ if isinstance(provider_config, Mapping) and provider_config.get("protocol"):
39
+ detail["protocol"] = str(provider_config["protocol"])
40
+ for command in commands:
41
+ resolved = shutil.which(command)
42
+ if resolved:
43
+ detail.update({"command": command, "path": resolved})
44
+ return DoctorCheck(
45
+ name="runtime.local_cli",
46
+ status=STATUS_PASS,
47
+ lane=lane_id,
48
+ message=f"{command} found",
49
+ detail=detail,
50
+ )
51
+ return DoctorCheck(
52
+ name="runtime.local_cli",
53
+ status=STATUS_WARN,
54
+ lane=lane_id,
55
+ message=f"none of the candidate commands were found: {', '.join(commands)}",
56
+ detail=detail,
57
+ remediation=local_cli_remediation(
58
+ commands,
59
+ str(detail.get("command_env", "")),
60
+ ),
61
+ )
62
+
63
+
64
+ def check_local_cli_probe(
65
+ lane_id: str,
66
+ lane: Mapping[str, Any],
67
+ *,
68
+ probe_runtime: bool,
69
+ http_timeout: int,
70
+ ) -> DoctorCheck:
71
+ if not probe_runtime:
72
+ return DoctorCheck(
73
+ name="runtime.local_cli.probe",
74
+ status=STATUS_SKIP,
75
+ lane=lane_id,
76
+ message="local CLI probing skipped; pass --probe-runtime to run a harmless version/help command",
77
+ )
78
+ resolved_pair = resolved_local_cli_command(lane)
79
+ if resolved_pair is None:
80
+ command = local_cli_command(lane)
81
+ return DoctorCheck(
82
+ name="runtime.local_cli.probe",
83
+ status=STATUS_WARN,
84
+ lane=lane_id,
85
+ message=f"{command} was not found, so runtime probe could not run",
86
+ detail={"command": command},
87
+ remediation=local_cli_remediation([command]),
88
+ )
89
+ command, resolved = resolved_pair
90
+ probe_args = local_cli_probe_args(lane, command)
91
+ timeout_seconds = local_cli_probe_timeout(lane, http_timeout)
92
+ child_env, env_detail = local_cli_probe_env(lane)
93
+ try:
94
+ completed = subprocess.run(
95
+ [resolved, *probe_args],
96
+ capture_output=True,
97
+ text=True,
98
+ check=False,
99
+ timeout=timeout_seconds,
100
+ env=child_env,
101
+ )
102
+ except (OSError, subprocess.TimeoutExpired) as exc:
103
+ return DoctorCheck(
104
+ name="runtime.local_cli.probe",
105
+ status=STATUS_WARN,
106
+ lane=lane_id,
107
+ message=f"{command} probe failed: {exc}",
108
+ detail={
109
+ "command": command,
110
+ "path": resolved,
111
+ "args": list(probe_args),
112
+ "timeout_seconds": timeout_seconds,
113
+ **env_detail,
114
+ },
115
+ remediation=local_cli_probe_remediation(command, probe_args, lane),
116
+ )
117
+ output = (completed.stdout or completed.stderr or "").strip()
118
+ provider_config = lane.get("provider_config", {})
119
+ json_detail: dict[str, Any] = {}
120
+ json_message = ""
121
+ if isinstance(provider_config, Mapping) and provider_config.get("doctor_probe_expect_json"):
122
+ status, json_message, json_detail = evaluate_json_probe(
123
+ provider_config,
124
+ output,
125
+ returncode=completed.returncode,
126
+ )
127
+ else:
128
+ status = STATUS_PASS if completed.returncode == 0 else STATUS_WARN
129
+ return DoctorCheck(
130
+ name="runtime.local_cli.probe",
131
+ status=status,
132
+ lane=lane_id,
133
+ message=(
134
+ f"{command} {json_message}"
135
+ if json_message
136
+ else (
137
+ f"{command} probe succeeded"
138
+ if status == STATUS_PASS
139
+ else f"{command} probe exited {completed.returncode}"
140
+ )
141
+ ),
142
+ detail={
143
+ "command": command,
144
+ "path": resolved,
145
+ "args": list(probe_args),
146
+ "timeout_seconds": timeout_seconds,
147
+ "returncode": completed.returncode,
148
+ **env_detail,
149
+ **json_detail,
150
+ **auth_probe_output_detail(output),
151
+ },
152
+ remediation=(
153
+ None
154
+ if status == STATUS_PASS
155
+ else local_cli_probe_remediation(
156
+ command,
157
+ probe_args,
158
+ lane,
159
+ auth_error_detected=bool(json_detail.get("auth_error_detected")),
160
+ )
161
+ ),
162
+ )
@@ -0,0 +1,47 @@
1
+ """Provider local CLI command discovery helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from typing import Any, Mapping
8
+
9
+ from .common import as_sequence
10
+
11
+
12
+ def candidate_local_cli_commands(lane: Mapping[str, Any]) -> list[str]:
13
+ provider_config = lane.get("provider_config", {})
14
+ commands: list[str] = []
15
+ if isinstance(provider_config, Mapping):
16
+ command_env = str(provider_config.get("command_env", ""))
17
+ if command_env and os.environ.get(command_env):
18
+ commands.append(str(os.environ[command_env]))
19
+ if provider_config.get("command"):
20
+ commands.append(str(provider_config["command"]))
21
+ for command in as_sequence(provider_config.get("alternate_commands", [])):
22
+ if command:
23
+ commands.append(str(command))
24
+ if not commands:
25
+ provider = str(lane.get("provider", ""))
26
+ commands.append(provider.replace("_", "-"))
27
+ deduped: list[str] = []
28
+ for command in commands:
29
+ if command and command not in deduped:
30
+ deduped.append(command)
31
+ return deduped
32
+
33
+
34
+ def local_cli_command(lane: Mapping[str, Any]) -> str:
35
+ candidates = candidate_local_cli_commands(lane)
36
+ if candidates:
37
+ return candidates[0]
38
+ provider = str(lane.get("provider", "")).replace("_", "-")
39
+ return provider or "unknown"
40
+
41
+
42
+ def resolved_local_cli_command(lane: Mapping[str, Any]) -> tuple[str, str] | None:
43
+ for command in candidate_local_cli_commands(lane):
44
+ resolved = shutil.which(command)
45
+ if resolved:
46
+ return command, resolved
47
+ return None
@@ -0,0 +1,70 @@
1
+ """Provider local CLI probe argument, timeout, and environment helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .common import (
10
+ SUPPORTED_TOKEN_FILE_ENV_NAMES,
11
+ as_sequence,
12
+ code_mower_secrets,
13
+ )
14
+
15
+
16
+ def local_cli_probe_args(lane: Mapping[str, Any], command: str) -> tuple[str, ...]:
17
+ provider_config = lane.get("provider_config", {})
18
+ if isinstance(provider_config, Mapping):
19
+ raw_probe = provider_config.get("doctor_probe_args")
20
+ if isinstance(raw_probe, (list, tuple)) and raw_probe:
21
+ return tuple(str(part) for part in raw_probe)
22
+ provider = str(lane.get("provider") or "")
23
+ if provider in {"gemini", "antigravity", "coderabbit", "claude", "codex"}:
24
+ return ("--version",)
25
+ return ("--help",)
26
+
27
+
28
+ def local_cli_probe_timeout(lane: Mapping[str, Any], default_timeout: int) -> int:
29
+ provider_config = lane.get("provider_config", {})
30
+ if not isinstance(provider_config, Mapping):
31
+ return default_timeout
32
+ raw_timeout = provider_config.get("doctor_probe_timeout_seconds")
33
+ if raw_timeout is None:
34
+ return default_timeout
35
+ try:
36
+ return max(1, int(raw_timeout))
37
+ except (TypeError, ValueError):
38
+ return default_timeout
39
+
40
+
41
+ def local_cli_probe_env(lane: Mapping[str, Any]) -> tuple[dict[str, str], dict[str, Any]]:
42
+ child_env = os.environ.copy()
43
+ forwarded: list[str] = []
44
+
45
+ token_names: list[str] = []
46
+ for item in as_sequence(lane.get("token_env", [])):
47
+ token_names.append(str(item))
48
+ for group in as_sequence(lane.get("token_env_any", [])):
49
+ for item in as_sequence(group):
50
+ token_names.append(str(item))
51
+
52
+ for name in token_names:
53
+ if name not in SUPPORTED_TOKEN_FILE_ENV_NAMES or child_env.get(name):
54
+ continue
55
+ path_text = child_env.get(f"{name}_FILE", "").strip()
56
+ if not path_text:
57
+ continue
58
+ try:
59
+ result = code_mower_secrets.read_secret_file(
60
+ Path(path_text),
61
+ supported_env_names=SUPPORTED_TOKEN_FILE_ENV_NAMES,
62
+ )
63
+ except OSError:
64
+ continue
65
+ if not result.ok:
66
+ continue
67
+ child_env[name] = result.value
68
+ forwarded.append(f"{name}_FILE")
69
+
70
+ return child_env, {"token_file_env_forwarded": sorted(set(forwarded))}
@@ -0,0 +1,20 @@
1
+ """Provider auth smoke-probe compatibility facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .provider_probe_auth import (
6
+ probe_auth_error_detail,
7
+ probe_error_value_is_clean,
8
+ )
9
+ from .provider_probe_evaluation import evaluate_json_probe
10
+ from .provider_probe_json import json_field, parse_probe_json
11
+ from .provider_probe_remediation import local_cli_probe_remediation
12
+
13
+ __all__ = (
14
+ "evaluate_json_probe",
15
+ "json_field",
16
+ "local_cli_probe_remediation",
17
+ "parse_probe_json",
18
+ "probe_auth_error_detail",
19
+ "probe_error_value_is_clean",
20
+ )
@@ -0,0 +1,52 @@
1
+ """Provider probe auth-error classification helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .provider_probe_json import json_field
8
+
9
+
10
+ def probe_error_value_is_clean(value: Any) -> bool:
11
+ return value is None or value is False or value == "" or value == 0
12
+
13
+
14
+ def probe_auth_error_detail(
15
+ payload: Mapping[str, Any],
16
+ error_fields: tuple[str, ...],
17
+ auth_status_fields: tuple[str, ...],
18
+ output: str,
19
+ ) -> dict[str, Any]:
20
+ detail: dict[str, Any] = {}
21
+ for field in auth_status_fields:
22
+ status_value = json_field(payload, field)
23
+ if status_value is None:
24
+ continue
25
+ status_text = str(status_value).strip()
26
+ if status_text in {"401", "403"}:
27
+ detail["auth_status_code"] = status_text
28
+ detail["auth_status_field"] = field
29
+ detail["auth_error_detected"] = True
30
+ break
31
+
32
+ lowered_output = output.lower()
33
+ auth_markers = (
34
+ "invalid authentication",
35
+ "authentication credentials",
36
+ "unauthorized",
37
+ "forbidden",
38
+ "not authenticated",
39
+ "auth token",
40
+ "api key",
41
+ "oauth",
42
+ )
43
+ has_dirty_error = any(
44
+ not probe_error_value_is_clean(json_field(payload, field))
45
+ for field in error_fields
46
+ )
47
+ if has_dirty_error and any(marker in lowered_output for marker in auth_markers):
48
+ detail["auth_error_detected"] = True
49
+ return detail
50
+
51
+
52
+ __all__ = ("probe_auth_error_detail", "probe_error_value_is_clean")
@@ -0,0 +1,109 @@
1
+ """Provider auth smoke-probe evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .common import STATUS_PASS, STATUS_WARN, as_sequence
8
+ from .provider_probe_auth import probe_auth_error_detail, probe_error_value_is_clean
9
+ from .provider_probe_json import json_field, parse_probe_json
10
+
11
+
12
+ def evaluate_json_probe(
13
+ provider_config: Mapping[str, Any],
14
+ output: str,
15
+ *,
16
+ returncode: int,
17
+ ) -> tuple[str, str, dict[str, Any]]:
18
+ payload, parse_detail = parse_probe_json(output)
19
+ detail: dict[str, Any] = dict(parse_detail)
20
+ if payload is None:
21
+ if returncode != 0:
22
+ return (
23
+ STATUS_WARN,
24
+ f"probe exited {returncode} without parseable JSON",
25
+ detail,
26
+ )
27
+ return (
28
+ STATUS_WARN,
29
+ "probe output was not parseable JSON",
30
+ detail,
31
+ )
32
+
33
+ error_fields = tuple(
34
+ str(field)
35
+ for field in as_sequence(provider_config.get("doctor_probe_error_fields", []))
36
+ if str(field).strip()
37
+ )
38
+ dirty_errors = []
39
+ for field in error_fields:
40
+ value = json_field(payload, field)
41
+ if not probe_error_value_is_clean(value):
42
+ dirty_errors.append(field)
43
+ detail["error_fields"] = list(error_fields)
44
+ detail["dirty_error_fields"] = dirty_errors
45
+ auth_status_fields = tuple(
46
+ str(field)
47
+ for field in as_sequence(
48
+ provider_config.get(
49
+ "doctor_probe_auth_status_fields",
50
+ ("api_error_status",),
51
+ )
52
+ )
53
+ if str(field).strip()
54
+ )
55
+ detail["auth_status_fields"] = list(auth_status_fields)
56
+ detail.update(
57
+ probe_auth_error_detail(
58
+ payload,
59
+ error_fields,
60
+ auth_status_fields,
61
+ output,
62
+ )
63
+ )
64
+
65
+ field = str(provider_config.get("doctor_probe_expect_json_field", "")).strip()
66
+ expected = provider_config.get("doctor_probe_expect_json_value")
67
+ response_matches = True
68
+ if field:
69
+ value = json_field(payload, field)
70
+ response_matches = expected is None or str(value).strip() == str(expected).strip()
71
+ detail.update(
72
+ {
73
+ "response_field": field,
74
+ "response_present": value is not None,
75
+ "response_length": len(str(value)) if value is not None else 0,
76
+ "response_matches_expected": response_matches,
77
+ }
78
+ )
79
+
80
+ if returncode != 0:
81
+ if detail.get("auth_error_detected"):
82
+ return (
83
+ STATUS_WARN,
84
+ "probe reported provider authentication failure",
85
+ detail,
86
+ )
87
+ return STATUS_WARN, f"probe exited {returncode}", detail
88
+ if dirty_errors:
89
+ if detail.get("auth_error_detected"):
90
+ return (
91
+ STATUS_WARN,
92
+ "probe reported provider authentication failure",
93
+ detail,
94
+ )
95
+ return (
96
+ STATUS_WARN,
97
+ "probe reported provider/API error fields",
98
+ detail,
99
+ )
100
+ if not response_matches:
101
+ return (
102
+ STATUS_WARN,
103
+ "probe response did not match expected sentinel",
104
+ detail,
105
+ )
106
+ return STATUS_PASS, "auth smoke probe succeeded", detail
107
+
108
+
109
+ __all__ = ("evaluate_json_probe",)
@@ -0,0 +1,45 @@
1
+ """Provider probe JSON parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Mapping
7
+
8
+
9
+ def parse_probe_json(output: str) -> tuple[Mapping[str, Any] | None, dict[str, Any]]:
10
+ text = output.strip()
11
+ if not text:
12
+ return None, {"json_parsed": False, "json_error": "empty_output"}
13
+
14
+ candidates = [text]
15
+ start = text.find("{")
16
+ end = text.rfind("}")
17
+ if start >= 0 and end > start:
18
+ candidates.append(text[start : end + 1])
19
+
20
+ last_error = "json"
21
+ for candidate in candidates:
22
+ try:
23
+ payload = json.loads(candidate)
24
+ except json.JSONDecodeError as exc:
25
+ last_error = str(exc)
26
+ continue
27
+ if isinstance(payload, Mapping):
28
+ return payload, {
29
+ "json_parsed": True,
30
+ "json_extracted": candidate != text,
31
+ }
32
+ return None, {"json_parsed": False, "json_error": "not_object"}
33
+ return None, {"json_parsed": False, "json_error": last_error}
34
+
35
+
36
+ def json_field(payload: Mapping[str, Any], dotted_field: str) -> Any:
37
+ value: Any = payload
38
+ for part in dotted_field.split("."):
39
+ if not isinstance(value, Mapping) or part not in value:
40
+ return None
41
+ value = value[part]
42
+ return value
43
+
44
+
45
+ __all__ = ("json_field", "parse_probe_json")
@@ -0,0 +1,39 @@
1
+ """Provider auth smoke-probe remediation text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+
8
+ def local_cli_probe_remediation(
9
+ command: str,
10
+ probe_args: tuple[str, ...],
11
+ lane: Mapping[str, Any],
12
+ *,
13
+ auth_error_detected: bool = False,
14
+ ) -> str:
15
+ provider = str(lane.get("provider") or "").strip()
16
+ if auth_error_detected and provider == "claude":
17
+ return (
18
+ "Run `claude auth status`, then run "
19
+ "`claude -p \"Reply with exactly: ok\" --output-format json`. "
20
+ "If status says logged in but the prompt returns 401, run "
21
+ "`code-mower claude-bounce --json` first to distinguish inherited "
22
+ "environment issues from stale local credentials. If clean_env also "
23
+ "returns 401, refresh Claude Code OAuth by removing stale local "
24
+ "Claude credentials and running `claude` to log in again. For "
25
+ "automation, prefer a provider/API-key credential path over "
26
+ "interactive Claude.ai OAuth."
27
+ )
28
+ if auth_error_detected:
29
+ return (
30
+ f"Run `{command} {' '.join(probe_args)}` manually, refresh provider "
31
+ "auth credentials or API keys, then rerun doctor --probe-runtime."
32
+ )
33
+ return (
34
+ f"Run `{command} {' '.join(probe_args)}` manually, fix CLI "
35
+ "installation or auth, then rerun doctor --probe-runtime."
36
+ )
37
+
38
+
39
+ __all__ = ("local_cli_probe_remediation",)