codeforerunner 0.4.2__py3-none-any.whl → 0.4.4__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.
@@ -1,6 +1,8 @@
1
+ """codeforerunner — prompt-first repo documentation tooling."""
2
+
1
3
  from importlib.metadata import PackageNotFoundError, version
2
4
 
3
5
  try:
4
6
  __version__ = version("codeforerunner")
5
- except PackageNotFoundError:
7
+ except PackageNotFoundError: # pragma: no cover
6
8
  __version__ = "0.0.0" # running from source without install
codeforerunner/bundle.py CHANGED
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
 
7
7
  def _package_prompts() -> Path:
8
+ """Return the path to the bundled prompts directory inside the package."""
8
9
  return Path(__file__).parent / "prompts"
9
10
 
10
11
 
codeforerunner/check.py CHANGED
@@ -11,6 +11,8 @@ from codeforerunner.config import CheckConfig
11
11
 
12
12
  @dataclass(frozen=True)
13
13
  class Violation:
14
+ """Single rule match: which doc, which line, which rule, and what it means."""
15
+
14
16
  path: Path
15
17
  line: int
16
18
  rule_id: str
@@ -19,6 +21,8 @@ class Violation:
19
21
 
20
22
  @dataclass(frozen=True)
21
23
  class _Rule:
24
+ """Drift detection rule: pattern to match, trigger files, and violation message."""
25
+
22
26
  id: str
23
27
  pattern: re.Pattern
24
28
  triggers: tuple[str, ...]
@@ -122,6 +126,7 @@ _CHANGELOG_FILENAME = "CHANGELOG.md"
122
126
 
123
127
 
124
128
  def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
129
+ """Return True if any pattern matches an existing file in repo."""
125
130
  for pat in patterns:
126
131
  if "*" in pat:
127
132
  parent = repo / Path(pat).parent
@@ -135,6 +140,7 @@ def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
135
140
 
136
141
 
137
142
  def _scanned_docs(repo: Path) -> list[Path]:
143
+ """Collect README.md and all *.md files under docs/ from repo."""
138
144
  docs: list[Path] = []
139
145
  readme = repo / "README.md"
140
146
  if readme.is_file():
@@ -146,6 +152,7 @@ def _scanned_docs(repo: Path) -> list[Path]:
146
152
 
147
153
 
148
154
  def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bool:
155
+ """Return True if doc's repo-relative path matches any ignore pattern."""
149
156
  if not ignore_patterns:
150
157
  return False
151
158
  try:
@@ -156,6 +163,7 @@ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bo
156
163
 
157
164
 
158
165
  def _current_version(repo: Path) -> str | None:
166
+ """Extract the package version from pyproject.toml, or None if absent/unparseable."""
159
167
  pyproject = repo / "pyproject.toml"
160
168
  if not pyproject.is_file():
161
169
  return None
@@ -173,6 +181,7 @@ def _check_version_drift(
173
181
  ignore_patterns: tuple[str, ...],
174
182
  enabled: set[str] | None,
175
183
  ) -> list[Violation]:
184
+ """Scan docs for pinned version strings that don't match pyproject.toml."""
176
185
  if enabled is not None and "RV1-version-drift" not in enabled:
177
186
  return []
178
187
  current = _current_version(repo)
codeforerunner/cli.py CHANGED
@@ -56,12 +56,13 @@ def cmd_doc(args: argparse.Namespace) -> int:
56
56
 
57
57
 
58
58
  def _doc_for(args: argparse.Namespace, task: str) -> int:
59
+ """Emit bundle for *task* by delegating to cmd_doc with a synthetic Namespace."""
59
60
  ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task)
60
61
  return cmd_doc(ns)
61
62
 
62
63
 
63
64
  def cmd_init(args: argparse.Namespace) -> int:
64
- """Default + --agents-only = onboarding bundle only. --full prepends scan."""
65
+ """Emit onboarding bundle; prepend scan bundle when --full is given."""
65
66
  if getattr(args, "full", False):
66
67
  sys.stdout.write("<!-- forerunner init --full: section 1/2 (scan) -->\n")
67
68
  rc = _doc_for(args, "scan")
@@ -72,6 +73,7 @@ def cmd_init(args: argparse.Namespace) -> int:
72
73
 
73
74
 
74
75
  def cmd_scan(args: argparse.Namespace) -> int:
76
+ """Emit the scan prompt bundle and hint about FORERUNNER_SCAN_DONE."""
75
77
  rc = _doc_for(args, "scan")
76
78
  if rc == 0:
77
79
  print(
@@ -102,6 +104,7 @@ def cmd_check(args: argparse.Namespace) -> int:
102
104
 
103
105
 
104
106
  def cmd_mcp_server(args: argparse.Namespace) -> int:
107
+ """Start the stdio MCP server exposing prompt bundles as tools."""
105
108
  from codeforerunner import mcp_server
106
109
  try:
107
110
  prompts_root = find_prompts_root(args.repo)
@@ -111,88 +114,22 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
111
114
  return mcp_server.serve(prompts_root)
112
115
 
113
116
 
114
- def cmd_generate(args: argparse.Namespace) -> int:
115
- """Resolve the bundle for <task> and send it to the configured provider.
116
-
117
- With --prompt-only (or when no provider/key/Ollama is reachable), outputs
118
- the assembled prompt bundle to stdout for the calling agent to process.
119
- """
120
- from codeforerunner import providers as _providers
121
- from codeforerunner.config import load_from_repo
122
-
123
- repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
124
- cfg = load_from_repo(repo_root)
125
-
126
- ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
127
-
128
- # --prompt-only: output the bundle and stop; the calling agent is the model.
129
- if getattr(args, "prompt_only", False):
130
- return cmd_doc(ns)
131
-
132
- bundle, rc = _get_bundle(ns)
133
- if rc != 0:
134
- return rc
135
-
136
- explicit_provider = args.provider or (cfg.provider if cfg else None)
137
- provider_name = explicit_provider or "anthropic"
138
- model = args.model or (cfg.model if cfg else None)
139
- provider_cls = _providers.get(provider_name)
140
- provider = provider_cls()
141
- model = model or provider.default_model
142
-
143
- env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
144
- api_key = os.environ.get(env_var)
145
- if api_key is None and provider_name != "ollama":
146
- if explicit_provider is None and _providers.ollama_available():
147
- provider_name = "ollama"
148
- provider_cls = _providers.get("ollama")
149
- provider = provider_cls()
150
- if not args.model:
151
- model = provider.default_model
152
- print("info: no API key; falling back to Ollama (local mode)", file=sys.stderr)
153
- elif explicit_provider is None:
154
- # Skill-mode auto-detect: no provider configured, no Ollama — output
155
- # the prompt bundle for the calling agent to process directly.
156
- sys.stdout.write(bundle)
157
- if sys.stdout.isatty():
158
- print(
159
- "\ninfo: no provider configured and Ollama not running.\n"
160
- " Prompt bundle written above — paste into your agent,\n"
161
- " or run: forerunner generate --prompt-only "
162
- f"{args.task}",
163
- file=sys.stderr,
164
- )
165
- return 0
166
- else:
167
- print(f"error: missing API key; set ${env_var}", file=sys.stderr)
168
- return 3
169
-
170
- if getattr(args, "stream", False):
171
- try:
172
- for chunk in provider.stream(prompt=bundle, model=model, api_key=api_key):
173
- sys.stdout.write(chunk)
174
- sys.stdout.flush()
175
- except _providers.ProviderError as e:
176
- print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
177
- return 4
178
- sys.stdout.write("\n")
179
- return 0
180
-
181
- try:
182
- result = provider.complete(prompt=bundle, model=model, api_key=api_key)
183
- except _providers.ProviderError as e:
184
- print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
185
- return 4
186
-
187
- sys.stdout.write(result.text.rstrip() + "\n")
188
- print(
189
- f"# {provider_name} {result.model} {result.usage or ''}".rstrip(),
190
- file=sys.stderr,
191
- )
117
+ def cmd_refresh(args: argparse.Namespace) -> int:
118
+ """Emit scan + check + all doc-task bundles to stdout for a full doc refresh."""
119
+ tasks = ["scan", "check", "readme", "api-docs", "stack-docs",
120
+ "diagrams", "flows", "version-audit", "audit"]
121
+ for i, task in enumerate(tasks):
122
+ ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task)
123
+ rc = cmd_doc(ns)
124
+ if rc != 0:
125
+ return rc
126
+ if i < len(tasks) - 1:
127
+ sys.stdout.write("\n---\n\n")
192
128
  return 0
193
129
 
194
130
 
195
131
  def cmd_doctor(args: argparse.Namespace) -> int:
132
+ """Run health checks and print a single-screen report; exit 1 on errors."""
196
133
  from codeforerunner import doctor
197
134
  from codeforerunner.config import CONFIG_FILENAME
198
135
  root = Path(args.repo).resolve() if args.repo else Path.cwd()
@@ -209,6 +146,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
209
146
 
210
147
 
211
148
  def build_parser() -> argparse.ArgumentParser:
149
+ """Build and return the top-level argument parser with all subcommands registered."""
212
150
  p = argparse.ArgumentParser(
213
151
  prog="forerunner",
214
152
  description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
@@ -265,18 +203,8 @@ def build_parser() -> argparse.ArgumentParser:
265
203
  )
266
204
  s_doctor.set_defaults(func=cmd_doctor)
267
205
 
268
- s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
269
- s_gen.add_argument("task", help="task basename under prompts/tasks/")
270
- s_gen.add_argument("--provider", help="override config provider")
271
- s_gen.add_argument("--model", help="override config model")
272
- s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
273
- s_gen.add_argument(
274
- "--prompt-only",
275
- dest="prompt_only",
276
- action="store_true",
277
- help="output the assembled prompt bundle to stdout; do not call a model (skill mode)",
278
- )
279
- s_gen.set_defaults(func=cmd_generate)
206
+ s_refresh = sub.add_parser("refresh", help="output all doc-refresh bundles in sequence (scan + check + all tasks)")
207
+ s_refresh.set_defaults(func=cmd_refresh)
280
208
 
281
209
  from codeforerunner import installer
282
210
  installer.add_subparser(sub)
@@ -285,6 +213,7 @@ def build_parser() -> argparse.ArgumentParser:
285
213
 
286
214
 
287
215
  def main(argv: Sequence[str] | None = None) -> int:
216
+ """Parse argv and dispatch to the appropriate subcommand handler."""
288
217
  parser = build_parser()
289
218
  args = parser.parse_args(argv)
290
219
  if not hasattr(args, "repo"):
codeforerunner/config.py CHANGED
@@ -3,13 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
+ from pathlib import Path
6
7
  from typing import Any
7
8
 
8
9
  import yaml
9
10
 
10
11
  CONFIG_FILENAME = "forerunner.config.yaml"
11
12
 
12
- _KNOWN_PROVIDERS = {"anthropic", "openai", "google", "ollama"}
13
13
  _KNOWN_SEVERITIES = {"HIGH", "MEDIUM", "LOW"}
14
14
 
15
15
 
@@ -19,6 +19,8 @@ class ConfigError(Exception):
19
19
 
20
20
  @dataclass(frozen=True)
21
21
  class CheckConfig:
22
+ """Drift-check task configuration: severity gates and path filters."""
23
+
22
24
  block_on: tuple[str, ...] = ("HIGH", "MEDIUM")
23
25
  warn_on: tuple[str, ...] = ("LOW",)
24
26
  enabled_rules: tuple[str, ...] | None = None # None = all rules enabled
@@ -27,6 +29,8 @@ class CheckConfig:
27
29
 
28
30
  @dataclass(frozen=True)
29
31
  class VersionAuditConfig:
32
+ """Version-audit task configuration: staleness window and live EOL data toggle."""
33
+
30
34
  enabled: bool = True
31
35
  stale_after_days: int = 30
32
36
  fetch_live_eol_data: bool = False
@@ -34,16 +38,16 @@ class VersionAuditConfig:
34
38
 
35
39
  @dataclass(frozen=True)
36
40
  class ForerunnerConfig:
37
- provider: str = "anthropic"
38
- model: str = "claude-opus-4-7"
41
+ """Top-level forerunner.config.yaml configuration."""
42
+
39
43
  approaching_eol_threshold_months: int = 6
40
44
  ignore_patterns: tuple[str, ...] = ()
41
- api_key_env: dict[str, str] = field(default_factory=dict)
42
45
  check: CheckConfig = field(default_factory=CheckConfig)
43
46
  version_audit: VersionAuditConfig = field(default_factory=VersionAuditConfig)
44
47
 
45
48
 
46
49
  def _require_type(value: Any, expected: type, field_name: str) -> Any:
50
+ """Raise ConfigError if value is not an instance of expected."""
47
51
  if not isinstance(value, expected):
48
52
  raise ConfigError(
49
53
  f"{field_name}: expected {expected.__name__}, got {type(value).__name__}"
@@ -52,6 +56,7 @@ def _require_type(value: Any, expected: type, field_name: str) -> Any:
52
56
 
53
57
 
54
58
  def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
59
+ """Coerce a list of strings to a tuple, raising ConfigError on bad input."""
55
60
  if value is None:
56
61
  return ()
57
62
  if not isinstance(value, list):
@@ -64,30 +69,8 @@ def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
64
69
  return tuple(out)
65
70
 
66
71
 
67
- def _parse_api_key_env(raw: Any) -> dict[str, str]:
68
- if raw is None:
69
- return {}
70
- if not isinstance(raw, dict):
71
- raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
72
- out: dict[str, str] = {}
73
- for k, v in raw.items():
74
- if not isinstance(k, str):
75
- raise ConfigError(
76
- f"api_key_env: keys must be strings, got {type(k).__name__}"
77
- )
78
- if k not in _KNOWN_PROVIDERS:
79
- raise ConfigError(
80
- f"api_key_env: unknown provider '{k}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
81
- )
82
- if not isinstance(v, str) or not v:
83
- raise ConfigError(
84
- f"api_key_env[{k}]: expected non-empty string, got {type(v).__name__}"
85
- )
86
- out[k] = v
87
- return out
88
-
89
-
90
72
  def _parse_check(raw: Any) -> CheckConfig:
73
+ """Parse the tasks.check mapping into a CheckConfig."""
91
74
  if raw is None:
92
75
  return CheckConfig()
93
76
  _require_type(raw, dict, "tasks.check")
@@ -114,6 +97,7 @@ def _parse_check(raw: Any) -> CheckConfig:
114
97
 
115
98
 
116
99
  def _to_int(value: Any, field_name: str) -> int:
100
+ """Convert value to int, raising ConfigError on failure."""
117
101
  try:
118
102
  return int(value)
119
103
  except (TypeError, ValueError) as e:
@@ -121,6 +105,7 @@ def _to_int(value: Any, field_name: str) -> int:
121
105
 
122
106
 
123
107
  def _parse_version_audit(raw: Any) -> VersionAuditConfig:
108
+ """Parse the tasks.version_audit mapping into a VersionAuditConfig."""
124
109
  if raw is None:
125
110
  return VersionAuditConfig()
126
111
  _require_type(raw, dict, "tasks.version_audit")
@@ -137,24 +122,21 @@ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
137
122
  return ForerunnerConfig()
138
123
  _require_type(raw, dict, "<root>")
139
124
 
140
- provider = raw.get("provider", "anthropic")
141
- _require_type(provider, str, "provider")
142
- if provider not in _KNOWN_PROVIDERS:
125
+ tasks_raw = raw.get("tasks")
126
+ tasks = tasks_raw if tasks_raw is not None else {}
127
+ _require_type(tasks, dict, "tasks")
128
+
129
+ eol_months = _to_int(
130
+ raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
131
+ )
132
+ if eol_months <= 0:
143
133
  raise ConfigError(
144
- f"provider: unknown '{provider}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
134
+ f"approaching_eol_threshold_months: must be a positive integer, got {eol_months}"
145
135
  )
146
136
 
147
- tasks = raw.get("tasks") or {}
148
- _require_type(tasks, dict, "tasks")
149
-
150
137
  return ForerunnerConfig(
151
- provider=provider,
152
- model=_require_type(raw.get("model", "claude-opus-4-7"), str, "model"),
153
- approaching_eol_threshold_months=_to_int(
154
- raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
155
- ),
138
+ approaching_eol_threshold_months=eol_months,
156
139
  ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
157
- api_key_env=_parse_api_key_env(raw.get("api_key_env")),
158
140
  check=_parse_check(tasks.get("check")),
159
141
  version_audit=_parse_version_audit(tasks.get("version_audit")),
160
142
  )
codeforerunner/doctor.py CHANGED
@@ -23,22 +23,18 @@ MARKETPLACE_REL = Path("plugins/codex/marketplace.json")
23
23
  MARKER_BEGIN = "<!-- forerunner:begin managed=codeforerunner.skill -->"
24
24
  MARKER_END = "<!-- forerunner:end -->"
25
25
 
26
- _DEFAULT_PROVIDER_ENV = {
27
- "anthropic": "ANTHROPIC_API_KEY",
28
- "openai": "OPENAI_API_KEY",
29
- "google": "GOOGLE_API_KEY",
30
- "ollama": "OLLAMA_HOST",
31
- }
32
-
33
26
 
34
27
  @dataclass(frozen=True)
35
28
  class Finding:
29
+ """Single health-check result with severity, check name, and human message."""
30
+
36
31
  severity: str # "ok" | "warn" | "error"
37
32
  check: str
38
33
  message: str
39
34
 
40
35
 
41
36
  def _installed_skill_destinations() -> list[Path]:
37
+ """Return default install paths for the codeforerunner skill across supported agents."""
42
38
  home = Path(os.path.expanduser("~"))
43
39
  return [
44
40
  home / ".codex/skills/codeforerunner/SKILL.md",
@@ -47,10 +43,12 @@ def _installed_skill_destinations() -> list[Path]:
47
43
 
48
44
 
49
45
  def _installed_marketplace_destination() -> Path:
46
+ """Return default install path for the Codex marketplace manifest."""
50
47
  return Path(os.path.expanduser("~")) / ".codex/marketplaces/codeforerunner.json"
51
48
 
52
49
 
53
50
  def _load_script_module(repo: Path, relpath: str, module_name: str):
51
+ """Load a Python script from the repo as a module with a unique name to avoid cache collisions."""
54
52
  # L3: unique name prevents stale cached module on repeated calls
55
53
  unique_name = f"{module_name}_{uuid.uuid4().hex}"
56
54
  script_path = repo / relpath
@@ -64,6 +62,7 @@ def _load_script_module(repo: Path, relpath: str, module_name: str):
64
62
 
65
63
 
66
64
  def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Finding]:
65
+ """Verify that all distributed skill copies match the canonical body."""
67
66
  if not run_scripts:
68
67
  return [
69
68
  Finding(
@@ -116,6 +115,7 @@ def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Find
116
115
 
117
116
 
118
117
  def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Finding]:
118
+ """Validate the Codex marketplace manifest using the repo validation script."""
119
119
  if not run_scripts:
120
120
  return [
121
121
  Finding(
@@ -143,6 +143,7 @@ def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Find
143
143
 
144
144
 
145
145
  def _check_installed_destinations(repo: Path) -> list[Finding]:
146
+ """Check whether installed skill and marketplace files are present and managed."""
146
147
  findings: list[Finding] = []
147
148
 
148
149
  for dest in _installed_skill_destinations():
@@ -227,6 +228,7 @@ def _check_installed_destinations(repo: Path) -> list[Finding]:
227
228
 
228
229
 
229
230
  def _check_config_loadable(repo: Path) -> list[Finding]:
231
+ """Try parsing forerunner.config.yaml; report error finding on ConfigError."""
230
232
  cfg_path = repo / CONFIG_FILENAME
231
233
  if not cfg_path.is_file():
232
234
  return [
@@ -243,92 +245,6 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
243
245
  return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
244
246
 
245
247
 
246
- def _skill_mode_active() -> bool:
247
- """True if any installed skill destination has managed markers — agent IS the model."""
248
- for dest in _installed_skill_destinations():
249
- if dest.exists():
250
- try:
251
- text = dest.read_text(encoding="utf-8")
252
- if MARKER_BEGIN in text and MARKER_END in text:
253
- return True
254
- except OSError:
255
- pass
256
- return False
257
-
258
-
259
- def _check_provider_api_key(repo: Path) -> list[Finding]:
260
- from codeforerunner.providers.ollama import is_available as _ollama_available
261
-
262
- cfg_path = repo / CONFIG_FILENAME
263
- if not cfg_path.is_file():
264
- if _ollama_available():
265
- return [
266
- Finding(
267
- "ok",
268
- "provider-api-key",
269
- "no config; Ollama running — generate will use local mode automatically",
270
- )
271
- ]
272
- if _skill_mode_active():
273
- return [
274
- Finding(
275
- "ok",
276
- "provider-api-key",
277
- "no config; skill mode active — the installed agent is the model, no API key needed",
278
- )
279
- ]
280
- return [
281
- Finding(
282
- "ok",
283
- "provider-api-key",
284
- f"no {CONFIG_FILENAME}; set an API key in config, start Ollama, or use skill mode (`forerunner generate --prompt-only`)",
285
- )
286
- ]
287
- try:
288
- cfg = load_from_repo(repo)
289
- except ConfigError:
290
- # config-loadable check will surface this; skip here
291
- return [
292
- Finding(
293
- "ok",
294
- "provider-api-key",
295
- "config unparseable; skipped (see config-loadable)",
296
- )
297
- ]
298
- if cfg is None: # pragma: no cover - defensive
299
- return [
300
- Finding(
301
- "ok",
302
- "provider-api-key",
303
- f"no {CONFIG_FILENAME}; provider key not checked",
304
- )
305
- ]
306
- provider = cfg.provider
307
- if provider == "ollama":
308
- return [
309
- Finding(
310
- "ok",
311
- "provider-api-key",
312
- "running in local mode (Ollama; no API key needed)",
313
- )
314
- ]
315
- env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")
316
- if os.environ.get(env_var):
317
- return [Finding("ok", "provider-api-key", f"{provider}: {env_var} is set")]
318
- return [
319
- Finding(
320
- "warn",
321
- "provider-api-key",
322
- f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run"
323
- + (
324
- " (use `--prompt-only` for key-free bundle output)"
325
- if _skill_mode_active()
326
- else ""
327
- ),
328
- )
329
- ]
330
-
331
-
332
248
  _STARTER_CONFIG = """\
333
249
  # forerunner.config.yaml — generated by `forerunner doctor --fix`
334
250
  # See https://github.com/derek-palmer/codeforerunner for docs.
@@ -348,31 +264,34 @@ tasks:
348
264
 
349
265
 
350
266
  def starter_config() -> str:
267
+ """Return the default forerunner.config.yaml content written by --fix."""
351
268
  return _STARTER_CONFIG
352
269
 
353
270
 
354
271
  def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
272
+ """Run all health checks against *repo* and return findings."""
355
273
  repo = repo.resolve()
356
274
  findings: list[Finding] = []
357
275
  findings.extend(_check_skill_body_parity(repo, run_scripts=run_scripts))
358
276
  findings.extend(_check_codex_marketplace(repo, run_scripts=run_scripts))
359
277
  findings.extend(_check_installed_destinations(repo))
360
278
  findings.extend(_check_config_loadable(repo))
361
- findings.extend(_check_provider_api_key(repo))
362
279
  return findings
363
280
 
364
281
 
365
282
  def format_report(findings: list[Finding]) -> str:
283
+ """Format findings as a human-readable report string with a summary line."""
366
284
  lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
367
285
  counts: dict[str, int] = {"ok": 0, "warn": 0, "error": 0}
368
286
  for f in findings:
369
- counts[f.severity] = counts.get(f.severity, 0) + 1
287
+ counts[f.severity] += 1
370
288
  summary = f"summary: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error"
371
289
  lines.append(summary)
372
290
  return "\n".join(lines)
373
291
 
374
292
 
375
293
  def main(argv: list[str] | None = None) -> int:
294
+ """Entry point for `forerunner doctor`; returns 1 when any finding is an error."""
376
295
  parser = argparse.ArgumentParser(
377
296
  prog="forerunner doctor",
378
297
  description="Single-screen health report for codeforerunner repo.",
@@ -41,15 +41,19 @@ TASK_SKILL_SLUGS: tuple[str, ...] = (
41
41
 
42
42
  @dataclass(frozen=True)
43
43
  class Target:
44
+ """Resolved install destination: agent name + absolute path."""
45
+
44
46
  name: str
45
47
  path: Path
46
48
 
47
49
 
48
50
  def _home() -> Path:
51
+ """Return the current user's home directory as a Path."""
49
52
  return Path(os.path.expanduser("~"))
50
53
 
51
54
 
52
55
  def resolve_target(agent: str, override: Path | None) -> Target:
56
+ """Return the default install Target for the given agent, or use override path."""
53
57
  if agent == "generic":
54
58
  if override is None:
55
59
  raise ValueError("generic target requires --path PATH")
@@ -126,6 +130,7 @@ def install_all_skills(
126
130
 
127
131
 
128
132
  def resolve_marketplace_target(agent: str, override: Path | None) -> Target:
133
+ """Return the marketplace install Target for the given agent, or use override path."""
129
134
  if agent == "generic":
130
135
  if override is None:
131
136
  raise ValueError("generic marketplace target requires --path PATH")
@@ -160,10 +165,12 @@ def extract_frontmatter(text: str) -> str:
160
165
 
161
166
 
162
167
  def _hash(s: str) -> str:
168
+ """Return SHA-256 hex digest of a UTF-8 encoded string."""
163
169
  return hashlib.sha256(s.encode("utf-8")).hexdigest()
164
170
 
165
171
 
166
172
  def _hash_bytes(b: bytes) -> str:
173
+ """Return SHA-256 hex digest of raw bytes."""
167
174
  return hashlib.sha256(b).hexdigest()
168
175
 
169
176
 
@@ -181,6 +188,7 @@ def render(source_text: str, dest_existing: str | None, agent: str) -> str:
181
188
 
182
189
 
183
190
  def find_markers(text: str) -> tuple[int, int] | None:
191
+ """Return (start, end) byte offsets of the managed region, or None if absent."""
184
192
  a = text.find(MARKER_BEGIN)
185
193
  if a < 0:
186
194
  return None
@@ -202,12 +210,15 @@ def overlay(dest_text: str, source_body: str) -> str:
202
210
 
203
211
  @dataclass
204
212
  class Plan:
213
+ """Pending install action computed by plan_install or plan_marketplace."""
214
+
205
215
  action: str # "create" | "update" | "skip" | "abort"
206
216
  reason: str
207
217
  target: Target
208
218
  new_content: str | None = None
209
219
 
210
220
  def write(self) -> None:
221
+ """Execute the plan: create or update the destination file."""
211
222
  if self.action in ("skip", "abort"):
212
223
  return
213
224
  assert self.new_content is not None
@@ -306,6 +317,7 @@ def install(
306
317
  out=None,
307
318
  err=None,
308
319
  ) -> int:
320
+ """Run one install operation (skill or marketplace). Returns an EXIT_* code."""
309
321
  out = out or sys.stdout
310
322
  err = err or sys.stderr
311
323
 
@@ -356,6 +368,7 @@ def install(
356
368
 
357
369
 
358
370
  def add_subparser(sub: argparse._SubParsersAction) -> None:
371
+ """Register the `forerunner install` subcommand onto *sub*."""
359
372
  p = sub.add_parser("install", help="install skill(s) into agent-specific directories (D.installer)")
360
373
  p.add_argument("agent", choices=["codex", "claude", "generic"], nargs="?",
361
374
  help="target agent (omit with --all to install to all detected agents)")
@@ -373,6 +386,7 @@ def add_subparser(sub: argparse._SubParsersAction) -> None:
373
386
 
374
387
 
375
388
  def _cli_entry(args: argparse.Namespace) -> int:
389
+ """Dispatch `forerunner install` subcommand from parsed CLI args."""
376
390
  root = Path(args.repo).resolve() if args.repo else Path.cwd()
377
391
 
378
392
  if getattr(args, "all", False):