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.
- {codeforerunner-0.4.1/src/codeforerunner.egg-info → codeforerunner-0.4.3}/PKG-INFO +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/README.md +1 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/pyproject.toml +7 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/__init__.py +3 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/bundle.py +2 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/check.py +2 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/cli.py +67 -28
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/config.py +19 -11
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/doctor.py +77 -25
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/installer.py +18 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/mcp_server.py +18 -8
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/__init__.py +1 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/anthropic.py +6 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/base.py +10 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/google.py +8 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/ollama.py +30 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/providers/openai.py +6 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3/src/codeforerunner.egg-info}/PKG-INFO +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/SOURCES.txt +1 -0
- codeforerunner-0.4.3/tests/test_bundle.py +64 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_check.py +63 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_cli.py +193 -5
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_config.py +37 -4
- codeforerunner-0.4.3/tests/test_doctor.py +461 -0
- codeforerunner-0.4.3/tests/test_installer.py +564 -0
- codeforerunner-0.4.3/tests/test_mcp_server.py +431 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_providers.py +139 -0
- codeforerunner-0.4.1/tests/test_doctor.py +0 -215
- codeforerunner-0.4.1/tests/test_installer.py +0 -229
- codeforerunner-0.4.1/tests/test_mcp_server.py +0 -219
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/LICENSE.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/setup.cfg +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/context-format.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/system/base.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/audit.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/check.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/flows.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/readme.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/review.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/scan.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/entry_points.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/requires.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/top_level.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_check_config_integration.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_examples.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_hooks_manifest.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.3}/tests/test_validate_codex_marketplace.py +0 -0
- {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.
|
|
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.
|
|
178
|
+
- uses: derek-palmer/codeforerunner@v0.4.2
|
|
179
179
|
```
|
|
180
180
|
|
|
181
181
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
18
|
-
"""Resolve
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
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[
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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]
|
|
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
|
|