codeforerunner 0.3.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,156 @@
1
+ """Drift detection for docs that claim files don't exist when they do."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from codeforerunner.config import CheckConfig
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Violation:
14
+ path: Path
15
+ line: int
16
+ rule_id: str
17
+ message: str
18
+
19
+
20
+ _RULES = [
21
+ (
22
+ "R1-no-cli",
23
+ re.compile(
24
+ r"(?i)no\s+CLI\s+exists"
25
+ r"|CLI\s+does\s+not\s+exist"
26
+ r"|not\s+currently\s+present:[^.]*\bCLI\b"
27
+ r"|Do\s+not\s+run\s+`forerunner`"
28
+ ),
29
+ ("src/codeforerunner/cli.py",),
30
+ "doc claims no CLI exists, but src/codeforerunner/cli.py is present",
31
+ ),
32
+ (
33
+ "R2-no-pre-commit",
34
+ re.compile(r"(?i)no\s+pre[- ]commit(\s+hook)?"),
35
+ (".pre-commit-hooks.yaml",),
36
+ "doc claims no pre-commit hook, but .pre-commit-hooks.yaml is present",
37
+ ),
38
+ (
39
+ "R3-no-ci",
40
+ re.compile(r"(?i)no\s+CI(\s+workflow)?"),
41
+ (".github/workflows/*.yml",),
42
+ "doc claims no CI workflow, but .github/workflows/*.yml is present",
43
+ ),
44
+ (
45
+ "R4-no-installer",
46
+ re.compile(r"(?i)no\s+installer"),
47
+ ("src/codeforerunner/installer.py",),
48
+ "doc claims no installer, but src/codeforerunner/installer.py is present",
49
+ ),
50
+ (
51
+ "R5-no-python-package",
52
+ re.compile(r"(?i)no\s+Python\s+package"),
53
+ ("pyproject.toml",),
54
+ "doc claims no Python package, but pyproject.toml is present",
55
+ ),
56
+ (
57
+ "R6-no-docker",
58
+ re.compile(r"(?i)no\s+Docker(\s+image)?|no\s+Dockerfile"),
59
+ ("Dockerfile", "compose.yml", "docker-compose.yml"),
60
+ "doc claims no Docker, but Dockerfile/compose file is present",
61
+ ),
62
+ (
63
+ "R6b-no-makefile",
64
+ re.compile(r"(?i)no\s+Makefile"),
65
+ ("Makefile",),
66
+ "doc claims no Makefile, but Makefile is present",
67
+ ),
68
+ (
69
+ "R7-no-mcp",
70
+ re.compile(r"(?i)no\s+MCP(\s+server)?"),
71
+ ("src/codeforerunner/mcp_server.py",),
72
+ "doc claims no MCP server, but src/codeforerunner/mcp_server.py is present",
73
+ ),
74
+ (
75
+ "R8-no-marketplace",
76
+ re.compile(r"(?i)no\s+marketplace(\s+manifest)?"),
77
+ ("plugins/codex/marketplace.json",),
78
+ "doc claims no marketplace, but plugins/codex/marketplace.json is present",
79
+ ),
80
+ ]
81
+
82
+
83
+ def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
84
+ for pat in patterns:
85
+ if "*" in pat:
86
+ parent = repo / Path(pat).parent
87
+ name = Path(pat).name
88
+ if parent.is_dir() and any(parent.glob(name)):
89
+ return True
90
+ else:
91
+ if (repo / pat).exists():
92
+ return True
93
+ return False
94
+
95
+
96
+ def _scanned_docs(repo: Path) -> list[Path]:
97
+ docs: list[Path] = []
98
+ readme = repo / "README.md"
99
+ if readme.is_file():
100
+ docs.append(readme)
101
+ docs_dir = repo / "docs"
102
+ if docs_dir.is_dir():
103
+ docs.extend(sorted(p for p in docs_dir.rglob("*.md") if p.is_file()))
104
+ return docs
105
+
106
+
107
+ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bool:
108
+ if not ignore_patterns:
109
+ return False
110
+ try:
111
+ rel = doc.relative_to(repo).as_posix()
112
+ except ValueError:
113
+ rel = doc.as_posix()
114
+ return any(fnmatch.fnmatch(rel, pat) for pat in ignore_patterns)
115
+
116
+
117
+ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
118
+ """Scan repo docs for drift; return list of violations.
119
+
120
+ `config` filters rules via `enabled_rules` and skips docs matching `ignore_paths`.
121
+ `None` config (default) preserves the pre-T25 behavior: all rules, no ignores.
122
+ """
123
+ repo = Path(repo)
124
+ enabled = set(config.enabled_rules) if (config and config.enabled_rules is not None) else None
125
+ ignore_patterns = config.ignore_paths if config else ()
126
+
127
+ active_rules = [
128
+ (rid, rx, msg)
129
+ for rid, rx, triggers, msg in _RULES
130
+ if _trigger_exists(repo, triggers) and (enabled is None or rid in enabled)
131
+ ]
132
+ if not active_rules:
133
+ return []
134
+
135
+ violations: list[Violation] = []
136
+ for doc in _scanned_docs(repo):
137
+ if _path_ignored(repo, doc, ignore_patterns):
138
+ continue
139
+ try:
140
+ text = doc.read_text(encoding="utf-8")
141
+ except (OSError, UnicodeDecodeError):
142
+ continue
143
+ for lineno, line in enumerate(text.splitlines(), start=1):
144
+ for rid, rx, msg in active_rules:
145
+ if rx.search(line):
146
+ violations.append(
147
+ Violation(path=doc, line=lineno, rule_id=rid, message=msg)
148
+ )
149
+ return violations
150
+
151
+
152
+ def format_violations(vs: list[Violation]) -> str:
153
+ """Format violations one per line for stderr."""
154
+ return "\n".join(
155
+ f"{v.path}:{v.line}: {v.rule_id}: {v.message}" for v in vs
156
+ )
codeforerunner/cli.py ADDED
@@ -0,0 +1,236 @@
1
+ """Thin CLI orchestration. Product logic lives in `prompts/`. See SPEC.md §D.cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
12
+ SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
13
+
14
+
15
+ def _repo_root(start: Path | None = None) -> Path:
16
+ """Walk up from cwd (or `start`) to a directory containing `prompts/tasks`."""
17
+ here = (start or Path.cwd()).resolve()
18
+ for candidate in [here, *here.parents]:
19
+ if (candidate / "prompts" / "tasks").is_dir():
20
+ return candidate
21
+ raise FileNotFoundError(
22
+ "could not locate codeforerunner repo root (no prompts/tasks/ found upward)"
23
+ )
24
+
25
+
26
+ def _read(path: Path) -> str:
27
+ return path.read_text(encoding="utf-8")
28
+
29
+
30
+ def cmd_doc(args: argparse.Namespace) -> int:
31
+ """Resolve `prompts/system/base.md` + `prompts/partials/*.md` + `prompts/tasks/<task>.md` to stdout."""
32
+ root = _repo_root(Path(args.repo) if args.repo else None)
33
+ task_path = root / "prompts" / "tasks" / f"{args.task}.md"
34
+ if not task_path.is_file():
35
+ print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
36
+ return 2
37
+
38
+ if (
39
+ args.task not in SCAN_EXEMPT_TASKS
40
+ and (root / "forerunner.config.yaml").is_file()
41
+ and not os.environ.get(SCAN_DONE_ENV)
42
+ ):
43
+ print(
44
+ f"warning: SPEC V2 scan-first — run `forerunner scan` first, "
45
+ f"then export {SCAN_DONE_ENV}=1 to silence this warning.",
46
+ file=sys.stderr,
47
+ )
48
+
49
+ parts: list[str] = []
50
+ base = root / "prompts" / "system" / "base.md"
51
+ if base.is_file():
52
+ parts.append(f"<!-- system: base.md -->\n{_read(base).rstrip()}")
53
+
54
+ partials_dir = root / "prompts" / "partials"
55
+ if partials_dir.is_dir():
56
+ for p in sorted(partials_dir.glob("*.md")):
57
+ parts.append(f"<!-- partial: {p.name} -->\n{_read(p).rstrip()}")
58
+
59
+ parts.append(f"<!-- task: {task_path.name} -->\n{_read(task_path).rstrip()}")
60
+ sys.stdout.write("\n\n".join(parts) + "\n")
61
+ return 0
62
+
63
+
64
+ def _doc_for(args: argparse.Namespace, task: str) -> int:
65
+ ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task)
66
+ return cmd_doc(ns)
67
+
68
+
69
+ def cmd_init(args: argparse.Namespace) -> int:
70
+ """Default + --agents-only = onboarding bundle only. --full prepends scan."""
71
+ if getattr(args, "full", False):
72
+ sys.stdout.write("<!-- forerunner init --full: section 1/2 (scan) -->\n")
73
+ rc = _doc_for(args, "scan")
74
+ if rc != 0:
75
+ return rc
76
+ sys.stdout.write("\n<!-- forerunner init --full: section 2/2 (onboarding) -->\n")
77
+ return _doc_for(args, "init-agent-onboarding")
78
+
79
+
80
+ def cmd_scan(args: argparse.Namespace) -> int:
81
+ rc = _doc_for(args, "scan")
82
+ if rc == 0:
83
+ print(
84
+ f"hint: export {SCAN_DONE_ENV}=1 in this shell to silence "
85
+ "scan-first warnings on follow-up `forerunner doc`/`init` calls.",
86
+ file=sys.stderr,
87
+ )
88
+ return rc
89
+
90
+
91
+ def cmd_check(args: argparse.Namespace) -> int:
92
+ """Run check rules when `forerunner.config.yaml` present. Silent no-op otherwise."""
93
+ try:
94
+ root = _repo_root(Path(args.repo) if args.repo else None)
95
+ except FileNotFoundError:
96
+ root = Path.cwd()
97
+ from codeforerunner import check as _check
98
+ from codeforerunner.config import ConfigError, load_from_repo
99
+ try:
100
+ cfg = load_from_repo(root)
101
+ except ConfigError as e:
102
+ print(f"forerunner check: invalid config: {e}", file=sys.stderr)
103
+ return 2
104
+ if cfg is None:
105
+ return 0
106
+ violations = _check.run(root, cfg.check)
107
+ if not violations:
108
+ return 0
109
+ sys.stderr.write(_check.format_violations(violations) + "\n")
110
+ return 1
111
+
112
+
113
+ def cmd_mcp_server(args: argparse.Namespace) -> int:
114
+ from codeforerunner import mcp_server
115
+ root = _repo_root(Path(args.repo) if args.repo else None)
116
+ return mcp_server.serve(root)
117
+
118
+
119
+ def cmd_generate(args: argparse.Namespace) -> int:
120
+ """Resolve the bundle for <task> and send it to the configured provider."""
121
+ from codeforerunner import providers as _providers
122
+ from codeforerunner.config import load_from_repo
123
+
124
+ root = _repo_root(Path(args.repo) if args.repo else None)
125
+ cfg = load_from_repo(root)
126
+
127
+ provider_name = args.provider or (cfg.provider if cfg else "anthropic")
128
+ model = args.model or (cfg.model if cfg else None)
129
+ provider_cls = _providers.get(provider_name)
130
+ provider = provider_cls()
131
+ model = model or provider.default_model
132
+
133
+ import io as _io
134
+ buf = _io.StringIO()
135
+ ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
136
+ # Temporarily redirect stdout to capture cmd_doc output.
137
+ real_stdout = sys.stdout
138
+ sys.stdout = buf
139
+ try:
140
+ rc = cmd_doc(ns)
141
+ finally:
142
+ sys.stdout = real_stdout
143
+ if rc != 0:
144
+ return rc
145
+
146
+ env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
147
+ api_key = os.environ.get(env_var)
148
+ if api_key is None and provider_name != "ollama":
149
+ print(
150
+ f"error: missing API key; set ${env_var}",
151
+ file=sys.stderr,
152
+ )
153
+ return 3
154
+
155
+ try:
156
+ result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
157
+ except _providers.ProviderError as e:
158
+ print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
159
+ return 4
160
+
161
+ sys.stdout.write(result.text.rstrip() + "\n")
162
+ print(
163
+ f"# {provider_name} {result.model} {result.usage or ''}".rstrip(),
164
+ file=sys.stderr,
165
+ )
166
+ return 0
167
+
168
+
169
+ def cmd_doctor(args: argparse.Namespace) -> int:
170
+ from codeforerunner import doctor
171
+ root = _repo_root(Path(args.repo) if args.repo else None)
172
+ findings = doctor.run(root)
173
+ sys.stdout.write(doctor.format_report(findings) + "\n")
174
+ return 1 if any(f.severity == "error" for f in findings) else 0
175
+
176
+
177
+ def build_parser() -> argparse.ArgumentParser:
178
+ p = argparse.ArgumentParser(
179
+ prog="forerunner",
180
+ description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
181
+ )
182
+ p.add_argument("--repo", help="path to repo root (defaults to cwd ancestor with prompts/tasks/)")
183
+ from codeforerunner import __version__ as _version
184
+ p.add_argument("--version", action="version", version=f"forerunner {_version}")
185
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="<cmd>")
186
+
187
+ s_init = sub.add_parser("init", help="resolve init-agent-onboarding prompt bundle to stdout")
188
+ init_scope = s_init.add_mutually_exclusive_group()
189
+ init_scope.add_argument(
190
+ "--full",
191
+ action="store_true",
192
+ help="prepend scan bundle before the onboarding bundle (scan-first per V2)",
193
+ )
194
+ init_scope.add_argument(
195
+ "--agents-only",
196
+ action="store_true",
197
+ help="explicit alias for the default scope (AGENTS.md update only)",
198
+ )
199
+ s_init.set_defaults(func=cmd_init)
200
+
201
+ s_scan = sub.add_parser("scan", help="resolve scan prompt bundle to stdout")
202
+ s_scan.set_defaults(func=cmd_scan)
203
+
204
+ s_doc = sub.add_parser("doc", help="resolve prompt bundle for <task> to stdout")
205
+ s_doc.add_argument("task", help="task name (basename without .md) under prompts/tasks/")
206
+ s_doc.set_defaults(func=cmd_doc)
207
+
208
+ s_check = sub.add_parser("check", help="run drift-detection rules against tracked docs")
209
+ s_check.set_defaults(func=cmd_check)
210
+
211
+ s_mcp = sub.add_parser("mcp-server", help="serve prompt bundles as MCP tools over stdio")
212
+ s_mcp.set_defaults(func=cmd_mcp_server)
213
+
214
+ s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
215
+ s_doctor.set_defaults(func=cmd_doctor)
216
+
217
+ s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
218
+ s_gen.add_argument("task", help="task basename under prompts/tasks/")
219
+ s_gen.add_argument("--provider", help="override config provider")
220
+ s_gen.add_argument("--model", help="override config model")
221
+ s_gen.set_defaults(func=cmd_generate)
222
+
223
+ from codeforerunner import installer
224
+ installer.add_subparser(sub)
225
+
226
+ return p
227
+
228
+
229
+ def main(argv: Sequence[str] | None = None) -> int:
230
+ parser = build_parser()
231
+ args = parser.parse_args(argv)
232
+ return args.func(args)
233
+
234
+
235
+ if __name__ == "__main__": # pragma: no cover
236
+ raise SystemExit(main())
@@ -0,0 +1,176 @@
1
+ """`forerunner.config.yaml` schema + loader. See SPEC.md §T25."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ CONFIG_FILENAME = "forerunner.config.yaml"
12
+
13
+ _KNOWN_PROVIDERS = {"anthropic", "openai", "google", "ollama"}
14
+ _KNOWN_SEVERITIES = {"HIGH", "MEDIUM", "LOW"}
15
+
16
+
17
+ class ConfigError(Exception):
18
+ """Schema violation in forerunner.config.yaml."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CheckConfig:
23
+ block_on: tuple[str, ...] = ("HIGH", "MEDIUM")
24
+ warn_on: tuple[str, ...] = ("LOW",)
25
+ enabled_rules: tuple[str, ...] | None = None # None = all rules enabled
26
+ ignore_paths: tuple[str, ...] = ()
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class VersionAuditConfig:
31
+ enabled: bool = True
32
+ stale_after_days: int = 30
33
+ fetch_live_eol_data: bool = False
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ForerunnerConfig:
38
+ provider: str = "anthropic"
39
+ 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
+ approaching_eol_threshold_months: int = 6
44
+ ignore_patterns: tuple[str, ...] = ()
45
+ api_key_env: dict[str, str] = field(default_factory=dict)
46
+ check: CheckConfig = field(default_factory=CheckConfig)
47
+ version_audit: VersionAuditConfig = field(default_factory=VersionAuditConfig)
48
+
49
+
50
+ def _require_type(value: Any, expected: type, field_name: str) -> Any:
51
+ if not isinstance(value, expected):
52
+ raise ConfigError(
53
+ f"{field_name}: expected {expected.__name__}, got {type(value).__name__}"
54
+ )
55
+ return value
56
+
57
+
58
+ def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
59
+ if value is None:
60
+ return ()
61
+ if not isinstance(value, list):
62
+ raise ConfigError(f"{field_name}: expected list, got {type(value).__name__}")
63
+ out: list[str] = []
64
+ for i, item in enumerate(value):
65
+ if not isinstance(item, str):
66
+ raise ConfigError(f"{field_name}[{i}]: expected string, got {type(item).__name__}")
67
+ out.append(item)
68
+ return tuple(out)
69
+
70
+
71
+ def _parse_api_key_env(raw: Any) -> dict[str, str]:
72
+ if raw is None:
73
+ return {}
74
+ if not isinstance(raw, dict):
75
+ raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
76
+ out: dict[str, str] = {}
77
+ for k, v in raw.items():
78
+ if not isinstance(k, str):
79
+ raise ConfigError(
80
+ f"api_key_env: keys must be strings, got {type(k).__name__}"
81
+ )
82
+ if k not in _KNOWN_PROVIDERS:
83
+ raise ConfigError(
84
+ f"api_key_env: unknown provider '{k}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
85
+ )
86
+ if not isinstance(v, str) or not v:
87
+ raise ConfigError(
88
+ f"api_key_env[{k}]: expected non-empty string, got {type(v).__name__}"
89
+ )
90
+ out[k] = v
91
+ return out
92
+
93
+
94
+ def _parse_check(raw: Any) -> CheckConfig:
95
+ if raw is None:
96
+ return CheckConfig()
97
+ _require_type(raw, dict, "tasks.check")
98
+ block_on = _coerce_str_tuple(raw.get("block_on", ["HIGH", "MEDIUM"]), "tasks.check.block_on")
99
+ warn_on = _coerce_str_tuple(raw.get("warn_on", ["LOW"]), "tasks.check.warn_on")
100
+ for sev in (*block_on, *warn_on):
101
+ if sev not in _KNOWN_SEVERITIES:
102
+ raise ConfigError(
103
+ f"tasks.check: unknown severity '{sev}' (expected one of {sorted(_KNOWN_SEVERITIES)})"
104
+ )
105
+ enabled_rules_raw = raw.get("enabled_rules")
106
+ enabled_rules = (
107
+ _coerce_str_tuple(enabled_rules_raw, "tasks.check.enabled_rules")
108
+ if enabled_rules_raw is not None
109
+ else None
110
+ )
111
+ ignore_paths = _coerce_str_tuple(raw.get("ignore_paths", []), "tasks.check.ignore_paths")
112
+ return CheckConfig(
113
+ block_on=block_on,
114
+ warn_on=warn_on,
115
+ enabled_rules=enabled_rules,
116
+ ignore_paths=ignore_paths,
117
+ )
118
+
119
+
120
+ def _parse_version_audit(raw: Any) -> VersionAuditConfig:
121
+ if raw is None:
122
+ return VersionAuditConfig()
123
+ _require_type(raw, dict, "tasks.version_audit")
124
+ return VersionAuditConfig(
125
+ enabled=bool(raw.get("enabled", True)),
126
+ stale_after_days=int(raw.get("stale_after_days", 30)),
127
+ fetch_live_eol_data=bool(raw.get("fetch_live_eol_data", False)),
128
+ )
129
+
130
+
131
+ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
132
+ """Validate a parsed YAML mapping into a ForerunnerConfig."""
133
+ if raw is None:
134
+ return ForerunnerConfig()
135
+ _require_type(raw, dict, "<root>")
136
+
137
+ provider = raw.get("provider", "anthropic")
138
+ _require_type(provider, str, "provider")
139
+ if provider not in _KNOWN_PROVIDERS:
140
+ raise ConfigError(
141
+ f"provider: unknown '{provider}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
142
+ )
143
+
144
+ tasks = raw.get("tasks") or {}
145
+ _require_type(tasks, dict, "tasks")
146
+
147
+ return ForerunnerConfig(
148
+ provider=provider,
149
+ 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)),
154
+ ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
155
+ api_key_env=_parse_api_key_env(raw.get("api_key_env")),
156
+ check=_parse_check(tasks.get("check")),
157
+ version_audit=_parse_version_audit(tasks.get("version_audit")),
158
+ )
159
+
160
+
161
+ def load(path: Path) -> ForerunnerConfig:
162
+ """Load and validate a config file. Empty file = all defaults."""
163
+ text = path.read_text(encoding="utf-8")
164
+ try:
165
+ raw = yaml.safe_load(text)
166
+ except yaml.YAMLError as e:
167
+ raise ConfigError(f"{path}: invalid YAML: {e}") from e
168
+ return parse(raw)
169
+
170
+
171
+ def load_from_repo(repo: Path) -> ForerunnerConfig | None:
172
+ """Return parsed config when `forerunner.config.yaml` is present, else None."""
173
+ p = repo / CONFIG_FILENAME
174
+ if not p.is_file():
175
+ return None
176
+ return load(p)