codeforerunner 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {codeforerunner-0.4.1/src/codeforerunner.egg-info → codeforerunner-0.4.3}/PKG-INFO +2 -2
  2. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/README.md +1 -1
  3. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/pyproject.toml +7 -1
  4. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/__init__.py +3 -1
  5. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/bundle.py +2 -1
  6. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/check.py +2 -0
  7. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/cli.py +67 -28
  8. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/config.py +19 -11
  9. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/doctor.py +77 -25
  10. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/installer.py +18 -2
  11. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/mcp_server.py +18 -8
  12. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/__init__.py +1 -0
  13. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/anthropic.py +6 -2
  14. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/base.py +10 -2
  15. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/google.py +8 -2
  16. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/ollama.py +30 -2
  17. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/openai.py +6 -2
  18. {codeforerunner-0.4.1 → codeforerunner-0.4.3/src/codeforerunner.egg-info}/PKG-INFO +2 -2
  19. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/SOURCES.txt +1 -0
  20. codeforerunner-0.4.3/tests/test_bundle.py +64 -0
  21. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_check.py +63 -0
  22. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_cli.py +193 -5
  23. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_config.py +37 -4
  24. codeforerunner-0.4.3/tests/test_doctor.py +461 -0
  25. codeforerunner-0.4.3/tests/test_installer.py +564 -0
  26. codeforerunner-0.4.3/tests/test_mcp_server.py +431 -0
  27. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_providers.py +139 -0
  28. codeforerunner-0.4.1/tests/test_doctor.py +0 -215
  29. codeforerunner-0.4.1/tests/test_installer.py +0 -229
  30. codeforerunner-0.4.1/tests/test_mcp_server.py +0 -219
  31. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/LICENSE.md +0 -0
  32. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/setup.cfg +0 -0
  33. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/context-format.md +0 -0
  34. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
  35. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
  36. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/system/base.md +0 -0
  37. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
  38. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/audit.md +0 -0
  39. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
  40. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/check.md +0 -0
  41. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
  42. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/flows.md +0 -0
  43. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
  44. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/readme.md +0 -0
  45. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/review.md +0 -0
  46. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/scan.md +0 -0
  47. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
  48. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
  49. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
  50. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/entry_points.txt +0 -0
  51. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/requires.txt +0 -0
  52. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/top_level.txt +0 -0
  53. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_check_config_integration.py +0 -0
  54. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_examples.py +0 -0
  55. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_hooks_manifest.py +0 -0
  56. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_validate_codex_marketplace.py +0 -0
  57. {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_workflows_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforerunner
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
5
5
  Author: Derek Palmer
6
6
  License-Expression: LicenseRef-Codeforerunner-SAL-0.1
@@ -175,7 +175,7 @@ forerunner check # run any time or as pre-commit hook
175
175
  ## GitHub Action
176
176
 
177
177
  ```yaml
178
- - uses: derek-palmer/codeforerunner@v0.3.2
178
+ - uses: derek-palmer/codeforerunner@v0.4.2
179
179
  ```
180
180
 
181
181
  No-op when `forerunner.config.yaml` is absent.
@@ -148,7 +148,7 @@ forerunner check # run any time or as pre-commit hook
148
148
  ## GitHub Action
149
149
 
150
150
  ```yaml
151
- - uses: derek-palmer/codeforerunner@v0.3.2
151
+ - uses: derek-palmer/codeforerunner@v0.4.2
152
152
  ```
153
153
 
154
154
  No-op when `forerunner.config.yaml` is absent.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codeforerunner"
7
- version = "0.4.1"
7
+ version = "0.4.3"
8
8
  description = "Model-agnostic repository documentation tooling (prompt-first; thin CLI)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -54,3 +54,9 @@ codeforerunner = ["py.typed", "prompts/**/*.md"]
54
54
 
55
55
  [tool.pytest.ini_options]
56
56
  testpaths = ["tests"]
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ "pydocstyle>=6.3.0",
61
+ "pytest-cov>=7.1.0",
62
+ ]
@@ -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
@@ -25,7 +25,8 @@ def find_prompts_root(repo_arg: str | Path | None = None) -> Path:
25
25
  )
26
26
 
27
27
  here = Path.cwd().resolve()
28
- for candidate in [here, *here.parents]:
28
+ parents = list(here.parents)
29
+ for candidate in [here, *parents[:10]]:
29
30
  if (candidate / "prompts" / "tasks").is_dir():
30
31
  return candidate / "prompts"
31
32
 
@@ -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
@@ -8,24 +8,24 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import Sequence
10
10
 
11
- from codeforerunner.bundle import find_prompts_root, resolve_bundle
11
+ from codeforerunner.bundle import find_prompts_root, resolve_bundle as _resolve_bundle
12
12
 
13
13
  SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
14
14
  SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
15
15
 
16
16
 
17
- def cmd_doc(args: argparse.Namespace) -> int:
18
- """Resolve base + partials + task bundle to stdout."""
17
+ def _get_bundle(args: argparse.Namespace) -> tuple[str, int]:
18
+ """Resolve bundle for args.task. Returns (bundle_text, exit_code). exit_code != 0 on error."""
19
19
  try:
20
20
  prompts_root = find_prompts_root(args.repo)
21
21
  except FileNotFoundError as e:
22
22
  print(f"error: {e}", file=sys.stderr)
23
- return 2
23
+ return "", 2
24
24
 
25
25
  task_path = prompts_root / "tasks" / f"{args.task}.md"
26
26
  if not task_path.is_file():
27
27
  print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
28
- return 2
28
+ return "", 2
29
29
 
30
30
  repo_root = Path(args.repo) if args.repo else Path.cwd()
31
31
  if (
@@ -40,10 +40,18 @@ def cmd_doc(args: argparse.Namespace) -> int:
40
40
  )
41
41
 
42
42
  try:
43
- sys.stdout.write(resolve_bundle(prompts_root, args.task))
43
+ return _resolve_bundle(prompts_root, args.task), 0
44
44
  except FileNotFoundError as e:
45
45
  print(f"error: {e}", file=sys.stderr)
46
- return 2
46
+ return "", 2
47
+
48
+
49
+ def cmd_doc(args: argparse.Namespace) -> int:
50
+ """Resolve base + partials + task bundle to stdout."""
51
+ bundle, rc = _get_bundle(args)
52
+ if rc != 0:
53
+ return rc
54
+ sys.stdout.write(bundle)
47
55
  return 0
48
56
 
49
57
 
@@ -53,7 +61,7 @@ def _doc_for(args: argparse.Namespace, task: str) -> int:
53
61
 
54
62
 
55
63
  def cmd_init(args: argparse.Namespace) -> int:
56
- """Default + --agents-only = onboarding bundle only. --full prepends scan."""
64
+ """Emit onboarding bundle; prepend scan bundle when --full is given."""
57
65
  if getattr(args, "full", False):
58
66
  sys.stdout.write("<!-- forerunner init --full: section 1/2 (scan) -->\n")
59
67
  rc = _doc_for(args, "scan")
@@ -64,6 +72,7 @@ def cmd_init(args: argparse.Namespace) -> int:
64
72
 
65
73
 
66
74
  def cmd_scan(args: argparse.Namespace) -> int:
75
+ """Emit the scan prompt bundle and hint about FORERUNNER_SCAN_DONE."""
67
76
  rc = _doc_for(args, "scan")
68
77
  if rc == 0:
69
78
  print(
@@ -94,6 +103,7 @@ def cmd_check(args: argparse.Namespace) -> int:
94
103
 
95
104
 
96
105
  def cmd_mcp_server(args: argparse.Namespace) -> int:
106
+ """Start the stdio MCP server exposing prompt bundles as tools."""
97
107
  from codeforerunner import mcp_server
98
108
  try:
99
109
  prompts_root = find_prompts_root(args.repo)
@@ -104,13 +114,28 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
104
114
 
105
115
 
106
116
  def cmd_generate(args: argparse.Namespace) -> int:
107
- """Resolve the bundle for <task> and send it to the configured provider."""
117
+ """Resolve the bundle for <task> and send it to the configured provider.
118
+
119
+ With --prompt-only (or when no provider/key/Ollama is reachable), outputs
120
+ the assembled prompt bundle to stdout for the calling agent to process.
121
+ """
108
122
  from codeforerunner import providers as _providers
109
123
  from codeforerunner.config import load_from_repo
110
124
 
111
125
  repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
112
126
  cfg = load_from_repo(repo_root)
113
127
 
128
+ ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
129
+
130
+ # --prompt-only: output the bundle and stop; the calling agent is the model.
131
+ if getattr(args, "prompt_only", False):
132
+ return cmd_doc(ns)
133
+
134
+ bundle, rc = _get_bundle(ns)
135
+ if rc != 0:
136
+ return rc
137
+
138
+
114
139
  explicit_provider = args.provider or (cfg.provider if cfg else None)
115
140
  provider_name = explicit_provider or "anthropic"
116
141
  model = args.model or (cfg.model if cfg else None)
@@ -118,18 +143,6 @@ def cmd_generate(args: argparse.Namespace) -> int:
118
143
  provider = provider_cls()
119
144
  model = model or provider.default_model
120
145
 
121
- import io as _io
122
- buf = _io.StringIO()
123
- ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
124
- real_stdout = sys.stdout
125
- sys.stdout = buf
126
- try:
127
- rc = cmd_doc(ns)
128
- finally:
129
- sys.stdout = real_stdout
130
- if rc != 0:
131
- return rc
132
-
133
146
  env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
134
147
  api_key = os.environ.get(env_var)
135
148
  if api_key is None and provider_name != "ollama":
@@ -140,16 +153,26 @@ def cmd_generate(args: argparse.Namespace) -> int:
140
153
  if not args.model:
141
154
  model = provider.default_model
142
155
  print("info: no API key; falling back to Ollama (local mode)", file=sys.stderr)
156
+ elif explicit_provider is None:
157
+ # Skill-mode auto-detect: no provider configured, no Ollama — output
158
+ # the prompt bundle for the calling agent to process directly.
159
+ sys.stdout.write(bundle)
160
+ if sys.stdout.isatty():
161
+ print(
162
+ "\ninfo: no provider configured and Ollama not running.\n"
163
+ " Prompt bundle written above — paste into your agent,\n"
164
+ " or run: forerunner generate --prompt-only "
165
+ f"{args.task}",
166
+ file=sys.stderr,
167
+ )
168
+ return 0
143
169
  else:
144
- msg = f"error: missing API key; set ${env_var}"
145
- if explicit_provider is None:
146
- msg += "\nhint: start Ollama for keyless local generation (https://ollama.com)"
147
- print(msg, file=sys.stderr)
170
+ print(f"error: missing API key; set ${env_var}", file=sys.stderr)
148
171
  return 3
149
172
 
150
173
  if getattr(args, "stream", False):
151
174
  try:
152
- for chunk in provider.stream(prompt=buf.getvalue(), model=model, api_key=api_key):
175
+ for chunk in provider.stream(prompt=bundle, model=model, api_key=api_key):
153
176
  sys.stdout.write(chunk)
154
177
  sys.stdout.flush()
155
178
  except _providers.ProviderError as e:
@@ -159,7 +182,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
159
182
  return 0
160
183
 
161
184
  try:
162
- result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
185
+ result = provider.complete(prompt=bundle, model=model, api_key=api_key)
163
186
  except _providers.ProviderError as e:
164
187
  print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
165
188
  return 4
@@ -173,6 +196,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
173
196
 
174
197
 
175
198
  def cmd_doctor(args: argparse.Namespace) -> int:
199
+ """Run health checks and print a single-screen report; exit 1 on errors."""
176
200
  from codeforerunner import doctor
177
201
  from codeforerunner.config import CONFIG_FILENAME
178
202
  root = Path(args.repo).resolve() if args.repo else Path.cwd()
@@ -183,12 +207,13 @@ def cmd_doctor(args: argparse.Namespace) -> int:
183
207
  print(f"wrote {cfg_path}", file=sys.stderr)
184
208
  else:
185
209
  print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
186
- findings = doctor.run(root)
210
+ findings = doctor.run(root, run_scripts=getattr(args, "run_scripts", False))
187
211
  sys.stdout.write(doctor.format_report(findings) + "\n")
188
212
  return 1 if any(f.severity == "error" for f in findings) else 0
189
213
 
190
214
 
191
215
  def build_parser() -> argparse.ArgumentParser:
216
+ """Build and return the top-level argument parser with all subcommands registered."""
192
217
  p = argparse.ArgumentParser(
193
218
  prog="forerunner",
194
219
  description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
@@ -236,6 +261,13 @@ def build_parser() -> argparse.ArgumentParser:
236
261
  action="store_true",
237
262
  help="write a starter forerunner.config.yaml if absent",
238
263
  )
264
+ s_doctor.add_argument(
265
+ "--run-scripts",
266
+ dest="run_scripts",
267
+ action="store_true",
268
+ default=False,
269
+ help="allow executing Python scripts from the target repo to validate skill copies (off by default)",
270
+ )
239
271
  s_doctor.set_defaults(func=cmd_doctor)
240
272
 
241
273
  s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
@@ -243,6 +275,12 @@ def build_parser() -> argparse.ArgumentParser:
243
275
  s_gen.add_argument("--provider", help="override config provider")
244
276
  s_gen.add_argument("--model", help="override config model")
245
277
  s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
278
+ s_gen.add_argument(
279
+ "--prompt-only",
280
+ dest="prompt_only",
281
+ action="store_true",
282
+ help="output the assembled prompt bundle to stdout; do not call a model (skill mode)",
283
+ )
246
284
  s_gen.set_defaults(func=cmd_generate)
247
285
 
248
286
  from codeforerunner import installer
@@ -252,6 +290,7 @@ def build_parser() -> argparse.ArgumentParser:
252
290
 
253
291
 
254
292
  def main(argv: Sequence[str] | None = None) -> int:
293
+ """Parse argv and dispatch to the appropriate subcommand handler."""
255
294
  parser = build_parser()
256
295
  args = parser.parse_args(argv)
257
296
  if not hasattr(args, "repo"):
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
- from pathlib import Path
7
6
  from typing import Any
8
7
 
9
8
  import yaml
@@ -20,6 +19,8 @@ class ConfigError(Exception):
20
19
 
21
20
  @dataclass(frozen=True)
22
21
  class CheckConfig:
22
+ """Drift-check task configuration: severity gates and path filters."""
23
+
23
24
  block_on: tuple[str, ...] = ("HIGH", "MEDIUM")
24
25
  warn_on: tuple[str, ...] = ("LOW",)
25
26
  enabled_rules: tuple[str, ...] | None = None # None = all rules enabled
@@ -28,6 +29,8 @@ class CheckConfig:
28
29
 
29
30
  @dataclass(frozen=True)
30
31
  class VersionAuditConfig:
32
+ """Version-audit task configuration: staleness window and live EOL data toggle."""
33
+
31
34
  enabled: bool = True
32
35
  stale_after_days: int = 30
33
36
  fetch_live_eol_data: bool = False
@@ -35,11 +38,10 @@ class VersionAuditConfig:
35
38
 
36
39
  @dataclass(frozen=True)
37
40
  class ForerunnerConfig:
41
+ """Top-level forerunner.config.yaml configuration."""
42
+
38
43
  provider: str = "anthropic"
39
44
  model: str = "claude-opus-4-7"
40
- output_dir: Path = field(default_factory=lambda: Path("docs"))
41
- context_max_files: int = 30
42
- context_max_lines_per_file: int = 300
43
45
  approaching_eol_threshold_months: int = 6
44
46
  ignore_patterns: tuple[str, ...] = ()
45
47
  api_key_env: dict[str, str] = field(default_factory=dict)
@@ -56,7 +58,7 @@ def _require_type(value: Any, expected: type, field_name: str) -> Any:
56
58
 
57
59
 
58
60
  def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
59
- if value is None:
61
+ if value is None: # pragma: no cover - callers supply defaults, never pass None
60
62
  return ()
61
63
  if not isinstance(value, list):
62
64
  raise ConfigError(f"{field_name}: expected list, got {type(value).__name__}")
@@ -75,7 +77,7 @@ def _parse_api_key_env(raw: Any) -> dict[str, str]:
75
77
  raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
76
78
  out: dict[str, str] = {}
77
79
  for k, v in raw.items():
78
- if not isinstance(k, str):
80
+ if not isinstance(k, str): # pragma: no cover - YAML keys are always strings
79
81
  raise ConfigError(
80
82
  f"api_key_env: keys must be strings, got {type(k).__name__}"
81
83
  )
@@ -117,13 +119,20 @@ def _parse_check(raw: Any) -> CheckConfig:
117
119
  )
118
120
 
119
121
 
122
+ def _to_int(value: Any, field_name: str) -> int:
123
+ try:
124
+ return int(value)
125
+ except (TypeError, ValueError) as e:
126
+ raise ConfigError(f"{field_name}: expected integer, got {value!r}") from e
127
+
128
+
120
129
  def _parse_version_audit(raw: Any) -> VersionAuditConfig:
121
130
  if raw is None:
122
131
  return VersionAuditConfig()
123
132
  _require_type(raw, dict, "tasks.version_audit")
124
133
  return VersionAuditConfig(
125
134
  enabled=bool(raw.get("enabled", True)),
126
- stale_after_days=int(raw.get("stale_after_days", 30)),
135
+ stale_after_days=_to_int(raw.get("stale_after_days", 30), "tasks.version_audit.stale_after_days"),
127
136
  fetch_live_eol_data=bool(raw.get("fetch_live_eol_data", False)),
128
137
  )
129
138
 
@@ -147,10 +156,9 @@ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
147
156
  return ForerunnerConfig(
148
157
  provider=provider,
149
158
  model=_require_type(raw.get("model", "claude-opus-4-7"), str, "model"),
150
- output_dir=Path(_require_type(raw.get("output_dir", "docs"), str, "output_dir")),
151
- context_max_files=int(raw.get("context_max_files", 30)),
152
- context_max_lines_per_file=int(raw.get("context_max_lines_per_file", 300)),
153
- approaching_eol_threshold_months=int(raw.get("approaching_eol_threshold_months", 6)),
159
+ approaching_eol_threshold_months=_to_int(
160
+ raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
161
+ ),
154
162
  ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
155
163
  api_key_env=_parse_api_key_env(raw.get("api_key_env")),
156
164
  check=_parse_check(tasks.get("check")),
@@ -6,6 +6,7 @@ import argparse
6
6
  import importlib.util
7
7
  import os
8
8
  import sys
9
+ import uuid
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
  from typing import Callable
@@ -32,6 +33,8 @@ _DEFAULT_PROVIDER_ENV = {
32
33
 
33
34
  @dataclass(frozen=True)
34
35
  class Finding:
36
+ """Single health-check result with severity, check name, and human message."""
37
+
35
38
  severity: str # "ok" | "warn" | "error"
36
39
  check: str
37
40
  message: str
@@ -50,17 +53,27 @@ def _installed_marketplace_destination() -> Path:
50
53
 
51
54
 
52
55
  def _load_script_module(repo: Path, relpath: str, module_name: str):
56
+ # L3: unique name prevents stale cached module on repeated calls
57
+ unique_name = f"{module_name}_{uuid.uuid4().hex}"
53
58
  script_path = repo / relpath
54
- spec = importlib.util.spec_from_file_location(module_name, script_path)
59
+ spec = importlib.util.spec_from_file_location(unique_name, script_path)
55
60
  if spec is None or spec.loader is None: # pragma: no cover - defensive
56
61
  raise RuntimeError(f"cannot load {script_path}")
57
62
  module = importlib.util.module_from_spec(spec)
58
- sys.modules[module_name] = module
63
+ sys.modules[unique_name] = module
59
64
  spec.loader.exec_module(module)
60
65
  return module
61
66
 
62
67
 
63
- def _check_skill_body_parity(repo: Path) -> list[Finding]:
68
+ def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Finding]:
69
+ if not run_scripts:
70
+ return [
71
+ Finding(
72
+ "warn",
73
+ "skill-body-parity",
74
+ "skipping script validation (pass --run-scripts to allow executing repo scripts)",
75
+ )
76
+ ]
64
77
  try:
65
78
  skill_mod = _load_script_module(
66
79
  repo, "scripts/validate_skill_copies.py", "_forerunner_doctor_skill_copies"
@@ -104,7 +117,15 @@ def _check_skill_body_parity(repo: Path) -> list[Finding]:
104
117
  return findings
105
118
 
106
119
 
107
- def _check_codex_marketplace(repo: Path) -> list[Finding]:
120
+ def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Finding]:
121
+ if not run_scripts:
122
+ return [
123
+ Finding(
124
+ "warn",
125
+ "codex-marketplace",
126
+ "skipping script validation (pass --run-scripts to allow executing repo scripts)",
127
+ )
128
+ ]
108
129
  try:
109
130
  mp_mod = _load_script_module(
110
131
  repo,
@@ -224,6 +245,19 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
224
245
  return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
225
246
 
226
247
 
248
+ def _skill_mode_active() -> bool:
249
+ """Return True if any installed skill destination has managed markers (agent is the model)."""
250
+ for dest in _installed_skill_destinations():
251
+ if dest.exists():
252
+ try:
253
+ text = dest.read_text(encoding="utf-8")
254
+ if MARKER_BEGIN in text and MARKER_END in text:
255
+ return True
256
+ except OSError:
257
+ pass
258
+ return False
259
+
260
+
227
261
  def _check_provider_api_key(repo: Path) -> list[Finding]:
228
262
  from codeforerunner.providers.ollama import is_available as _ollama_available
229
263
 
@@ -237,11 +271,19 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
237
271
  "no config; Ollama running — generate will use local mode automatically",
238
272
  )
239
273
  ]
274
+ if _skill_mode_active():
275
+ return [
276
+ Finding(
277
+ "ok",
278
+ "provider-api-key",
279
+ "no config; skill mode active — the installed agent is the model, no API key needed",
280
+ )
281
+ ]
240
282
  return [
241
283
  Finding(
242
284
  "ok",
243
285
  "provider-api-key",
244
- f"no {CONFIG_FILENAME}; set an API key in config or start Ollama for keyless local generation",
286
+ f"no {CONFIG_FILENAME}; set an API key in config, start Ollama, or use skill mode (`forerunner generate --prompt-only`)",
245
287
  )
246
288
  ]
247
289
  try:
@@ -275,6 +317,8 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
275
317
  env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")
276
318
  if os.environ.get(env_var):
277
319
  return [Finding("ok", "provider-api-key", f"{provider}: {env_var} is set")]
320
+ # Emit the same warning regardless of skill mode: an explicit provider in config
321
+ # means the user intends to use that provider, so a missing key is always a real problem.
278
322
  return [
279
323
  Finding(
280
324
  "warn",
@@ -288,27 +332,31 @@ _STARTER_CONFIG = """\
288
332
  # forerunner.config.yaml — generated by `forerunner doctor --fix`
289
333
  # See https://github.com/derek-palmer/codeforerunner for docs.
290
334
 
291
- enabled_rules:
292
- - R1-no-cli
293
- - R2-no-pre-commit
294
- - R3-no-ci
295
- - R4-no-installer
296
- - R5-no-python-package
297
- - R7-no-mcp
298
- - R8-no-marketplace
299
- ignore_paths: []
335
+ tasks:
336
+ check:
337
+ enabled_rules:
338
+ - R1-no-cli
339
+ - R2-no-pre-commit
340
+ - R3-no-ci
341
+ - R4-no-installer
342
+ - R5-no-python-package
343
+ - R7-no-mcp
344
+ - R8-no-marketplace
345
+ ignore_paths: []
300
346
  """
301
347
 
302
348
 
303
349
  def starter_config() -> str:
350
+ """Return the default forerunner.config.yaml content written by --fix."""
304
351
  return _STARTER_CONFIG
305
352
 
306
353
 
307
- def run(repo: Path) -> list[Finding]:
354
+ def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
355
+ """Run all health checks against *repo* and return findings."""
308
356
  repo = repo.resolve()
309
357
  findings: list[Finding] = []
310
- findings.extend(_check_skill_body_parity(repo))
311
- findings.extend(_check_codex_marketplace(repo))
358
+ findings.extend(_check_skill_body_parity(repo, run_scripts=run_scripts))
359
+ findings.extend(_check_codex_marketplace(repo, run_scripts=run_scripts))
312
360
  findings.extend(_check_installed_destinations(repo))
313
361
  findings.extend(_check_config_loadable(repo))
314
362
  findings.extend(_check_provider_api_key(repo))
@@ -316,20 +364,18 @@ def run(repo: Path) -> list[Finding]:
316
364
 
317
365
 
318
366
  def format_report(findings: list[Finding]) -> str:
367
+ """Format findings as a human-readable report string with a summary line."""
319
368
  lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
320
- counts = {"ok": 0, "warn": 0, "error": 0}
369
+ counts: dict[str, int] = {"ok": 0, "warn": 0, "error": 0}
321
370
  for f in findings:
322
- counts[f.severity] = counts.get(f.severity, 0) + 1
323
- summary = (
324
- f"summary: {counts.get('ok', 0)} ok, "
325
- f"{counts.get('warn', 0)} warn, "
326
- f"{counts.get('error', 0)} error"
327
- )
371
+ counts[f.severity] += 1
372
+ summary = f"summary: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error"
328
373
  lines.append(summary)
329
374
  return "\n".join(lines)
330
375
 
331
376
 
332
377
  def main(argv: list[str] | None = None) -> int:
378
+ """Entry point for `forerunner doctor`; returns 1 when any finding is an error."""
333
379
  parser = argparse.ArgumentParser(
334
380
  prog="forerunner doctor",
335
381
  description="Single-screen health report for codeforerunner repo.",
@@ -340,9 +386,15 @@ def main(argv: list[str] | None = None) -> int:
340
386
  default=Path.cwd(),
341
387
  help="repo root (default: cwd)",
342
388
  )
389
+ parser.add_argument(
390
+ "--run-scripts",
391
+ action="store_true",
392
+ default=False,
393
+ help="allow executing Python scripts from the target repo (off by default for safety)",
394
+ )
343
395
  args = parser.parse_args(argv)
344
396
 
345
- findings = run(args.repo)
397
+ findings = run(args.repo, run_scripts=args.run_scripts)
346
398
  print(format_report(findings))
347
399
  return 1 if any(f.severity == "error" for f in findings) else 0
348
400