codeforerunner 0.4.3__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.
- codeforerunner/bundle.py +1 -0
- codeforerunner/check.py +7 -0
- codeforerunner/cli.py +14 -91
- codeforerunner/config.py +17 -41
- codeforerunner/doctor.py +7 -91
- codeforerunner/installer.py +4 -0
- codeforerunner/mcp_server.py +5 -0
- codeforerunner/prompts/tasks/refresh.md +23 -0
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/METADATA +57 -77
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/RECORD +14 -19
- codeforerunner/providers/__init__.py +0 -38
- codeforerunner/providers/anthropic.py +0 -122
- codeforerunner/providers/base.py +0 -47
- codeforerunner/providers/google.py +0 -118
- codeforerunner/providers/ollama.py +0 -139
- codeforerunner/providers/openai.py +0 -117
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/WHEEL +0 -0
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/entry_points.txt +0 -0
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/licenses/LICENSE.md +0 -0
- {codeforerunner-0.4.3.dist-info → codeforerunner-0.4.4.dist-info}/top_level.txt +0 -0
codeforerunner/bundle.py
CHANGED
codeforerunner/check.py
CHANGED
|
@@ -21,6 +21,8 @@ class Violation:
|
|
|
21
21
|
|
|
22
22
|
@dataclass(frozen=True)
|
|
23
23
|
class _Rule:
|
|
24
|
+
"""Drift detection rule: pattern to match, trigger files, and violation message."""
|
|
25
|
+
|
|
24
26
|
id: str
|
|
25
27
|
pattern: re.Pattern
|
|
26
28
|
triggers: tuple[str, ...]
|
|
@@ -124,6 +126,7 @@ _CHANGELOG_FILENAME = "CHANGELOG.md"
|
|
|
124
126
|
|
|
125
127
|
|
|
126
128
|
def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
|
|
129
|
+
"""Return True if any pattern matches an existing file in repo."""
|
|
127
130
|
for pat in patterns:
|
|
128
131
|
if "*" in pat:
|
|
129
132
|
parent = repo / Path(pat).parent
|
|
@@ -137,6 +140,7 @@ def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
|
|
|
137
140
|
|
|
138
141
|
|
|
139
142
|
def _scanned_docs(repo: Path) -> list[Path]:
|
|
143
|
+
"""Collect README.md and all *.md files under docs/ from repo."""
|
|
140
144
|
docs: list[Path] = []
|
|
141
145
|
readme = repo / "README.md"
|
|
142
146
|
if readme.is_file():
|
|
@@ -148,6 +152,7 @@ def _scanned_docs(repo: Path) -> list[Path]:
|
|
|
148
152
|
|
|
149
153
|
|
|
150
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."""
|
|
151
156
|
if not ignore_patterns:
|
|
152
157
|
return False
|
|
153
158
|
try:
|
|
@@ -158,6 +163,7 @@ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bo
|
|
|
158
163
|
|
|
159
164
|
|
|
160
165
|
def _current_version(repo: Path) -> str | None:
|
|
166
|
+
"""Extract the package version from pyproject.toml, or None if absent/unparseable."""
|
|
161
167
|
pyproject = repo / "pyproject.toml"
|
|
162
168
|
if not pyproject.is_file():
|
|
163
169
|
return None
|
|
@@ -175,6 +181,7 @@ def _check_version_drift(
|
|
|
175
181
|
ignore_patterns: tuple[str, ...],
|
|
176
182
|
enabled: set[str] | None,
|
|
177
183
|
) -> list[Violation]:
|
|
184
|
+
"""Scan docs for pinned version strings that don't match pyproject.toml."""
|
|
178
185
|
if enabled is not None and "RV1-version-drift" not in enabled:
|
|
179
186
|
return []
|
|
180
187
|
current = _current_version(repo)
|
codeforerunner/cli.py
CHANGED
|
@@ -56,6 +56,7 @@ 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
|
|
|
@@ -113,85 +114,17 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
|
|
|
113
114
|
return mcp_server.serve(prompts_root)
|
|
114
115
|
|
|
115
116
|
|
|
116
|
-
def
|
|
117
|
-
"""
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
139
|
-
explicit_provider = args.provider or (cfg.provider if cfg else None)
|
|
140
|
-
provider_name = explicit_provider or "anthropic"
|
|
141
|
-
model = args.model or (cfg.model if cfg else None)
|
|
142
|
-
provider_cls = _providers.get(provider_name)
|
|
143
|
-
provider = provider_cls()
|
|
144
|
-
model = model or provider.default_model
|
|
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
|
-
if explicit_provider is None and _providers.ollama_available():
|
|
150
|
-
provider_name = "ollama"
|
|
151
|
-
provider_cls = _providers.get("ollama")
|
|
152
|
-
provider = provider_cls()
|
|
153
|
-
if not args.model:
|
|
154
|
-
model = provider.default_model
|
|
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
|
|
169
|
-
else:
|
|
170
|
-
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
|
|
171
|
-
return 3
|
|
172
|
-
|
|
173
|
-
if getattr(args, "stream", False):
|
|
174
|
-
try:
|
|
175
|
-
for chunk in provider.stream(prompt=bundle, model=model, api_key=api_key):
|
|
176
|
-
sys.stdout.write(chunk)
|
|
177
|
-
sys.stdout.flush()
|
|
178
|
-
except _providers.ProviderError as e:
|
|
179
|
-
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
180
|
-
return 4
|
|
181
|
-
sys.stdout.write("\n")
|
|
182
|
-
return 0
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
result = provider.complete(prompt=bundle, model=model, api_key=api_key)
|
|
186
|
-
except _providers.ProviderError as e:
|
|
187
|
-
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
188
|
-
return 4
|
|
189
|
-
|
|
190
|
-
sys.stdout.write(result.text.rstrip() + "\n")
|
|
191
|
-
print(
|
|
192
|
-
f"# {provider_name} {result.model} {result.usage or ''}".rstrip(),
|
|
193
|
-
file=sys.stderr,
|
|
194
|
-
)
|
|
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")
|
|
195
128
|
return 0
|
|
196
129
|
|
|
197
130
|
|
|
@@ -270,18 +203,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
270
203
|
)
|
|
271
204
|
s_doctor.set_defaults(func=cmd_doctor)
|
|
272
205
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
s_gen.add_argument("--provider", help="override config provider")
|
|
276
|
-
s_gen.add_argument("--model", help="override config model")
|
|
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
|
-
)
|
|
284
|
-
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)
|
|
285
208
|
|
|
286
209
|
from codeforerunner import installer
|
|
287
210
|
installer.add_subparser(sub)
|
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
|
|
|
@@ -40,16 +40,14 @@ class VersionAuditConfig:
|
|
|
40
40
|
class ForerunnerConfig:
|
|
41
41
|
"""Top-level forerunner.config.yaml configuration."""
|
|
42
42
|
|
|
43
|
-
provider: str = "anthropic"
|
|
44
|
-
model: str = "claude-opus-4-7"
|
|
45
43
|
approaching_eol_threshold_months: int = 6
|
|
46
44
|
ignore_patterns: tuple[str, ...] = ()
|
|
47
|
-
api_key_env: dict[str, str] = field(default_factory=dict)
|
|
48
45
|
check: CheckConfig = field(default_factory=CheckConfig)
|
|
49
46
|
version_audit: VersionAuditConfig = field(default_factory=VersionAuditConfig)
|
|
50
47
|
|
|
51
48
|
|
|
52
49
|
def _require_type(value: Any, expected: type, field_name: str) -> Any:
|
|
50
|
+
"""Raise ConfigError if value is not an instance of expected."""
|
|
53
51
|
if not isinstance(value, expected):
|
|
54
52
|
raise ConfigError(
|
|
55
53
|
f"{field_name}: expected {expected.__name__}, got {type(value).__name__}"
|
|
@@ -58,7 +56,8 @@ def _require_type(value: Any, expected: type, field_name: str) -> Any:
|
|
|
58
56
|
|
|
59
57
|
|
|
60
58
|
def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
|
|
61
|
-
|
|
59
|
+
"""Coerce a list of strings to a tuple, raising ConfigError on bad input."""
|
|
60
|
+
if value is None:
|
|
62
61
|
return ()
|
|
63
62
|
if not isinstance(value, list):
|
|
64
63
|
raise ConfigError(f"{field_name}: expected list, got {type(value).__name__}")
|
|
@@ -70,30 +69,8 @@ def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
|
|
|
70
69
|
return tuple(out)
|
|
71
70
|
|
|
72
71
|
|
|
73
|
-
def _parse_api_key_env(raw: Any) -> dict[str, str]:
|
|
74
|
-
if raw is None:
|
|
75
|
-
return {}
|
|
76
|
-
if not isinstance(raw, dict):
|
|
77
|
-
raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
|
|
78
|
-
out: dict[str, str] = {}
|
|
79
|
-
for k, v in raw.items():
|
|
80
|
-
if not isinstance(k, str): # pragma: no cover - YAML keys are always strings
|
|
81
|
-
raise ConfigError(
|
|
82
|
-
f"api_key_env: keys must be strings, got {type(k).__name__}"
|
|
83
|
-
)
|
|
84
|
-
if k not in _KNOWN_PROVIDERS:
|
|
85
|
-
raise ConfigError(
|
|
86
|
-
f"api_key_env: unknown provider '{k}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
|
|
87
|
-
)
|
|
88
|
-
if not isinstance(v, str) or not v:
|
|
89
|
-
raise ConfigError(
|
|
90
|
-
f"api_key_env[{k}]: expected non-empty string, got {type(v).__name__}"
|
|
91
|
-
)
|
|
92
|
-
out[k] = v
|
|
93
|
-
return out
|
|
94
|
-
|
|
95
|
-
|
|
96
72
|
def _parse_check(raw: Any) -> CheckConfig:
|
|
73
|
+
"""Parse the tasks.check mapping into a CheckConfig."""
|
|
97
74
|
if raw is None:
|
|
98
75
|
return CheckConfig()
|
|
99
76
|
_require_type(raw, dict, "tasks.check")
|
|
@@ -120,6 +97,7 @@ def _parse_check(raw: Any) -> CheckConfig:
|
|
|
120
97
|
|
|
121
98
|
|
|
122
99
|
def _to_int(value: Any, field_name: str) -> int:
|
|
100
|
+
"""Convert value to int, raising ConfigError on failure."""
|
|
123
101
|
try:
|
|
124
102
|
return int(value)
|
|
125
103
|
except (TypeError, ValueError) as e:
|
|
@@ -127,6 +105,7 @@ def _to_int(value: Any, field_name: str) -> int:
|
|
|
127
105
|
|
|
128
106
|
|
|
129
107
|
def _parse_version_audit(raw: Any) -> VersionAuditConfig:
|
|
108
|
+
"""Parse the tasks.version_audit mapping into a VersionAuditConfig."""
|
|
130
109
|
if raw is None:
|
|
131
110
|
return VersionAuditConfig()
|
|
132
111
|
_require_type(raw, dict, "tasks.version_audit")
|
|
@@ -143,24 +122,21 @@ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
|
|
|
143
122
|
return ForerunnerConfig()
|
|
144
123
|
_require_type(raw, dict, "<root>")
|
|
145
124
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
149
133
|
raise ConfigError(
|
|
150
|
-
f"
|
|
134
|
+
f"approaching_eol_threshold_months: must be a positive integer, got {eol_months}"
|
|
151
135
|
)
|
|
152
136
|
|
|
153
|
-
tasks = raw.get("tasks") or {}
|
|
154
|
-
_require_type(tasks, dict, "tasks")
|
|
155
|
-
|
|
156
137
|
return ForerunnerConfig(
|
|
157
|
-
|
|
158
|
-
model=_require_type(raw.get("model", "claude-opus-4-7"), str, "model"),
|
|
159
|
-
approaching_eol_threshold_months=_to_int(
|
|
160
|
-
raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
|
|
161
|
-
),
|
|
138
|
+
approaching_eol_threshold_months=eol_months,
|
|
162
139
|
ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
|
|
163
|
-
api_key_env=_parse_api_key_env(raw.get("api_key_env")),
|
|
164
140
|
check=_parse_check(tasks.get("check")),
|
|
165
141
|
version_audit=_parse_version_audit(tasks.get("version_audit")),
|
|
166
142
|
)
|
codeforerunner/doctor.py
CHANGED
|
@@ -23,13 +23,6 @@ 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:
|
|
@@ -41,6 +34,7 @@ class Finding:
|
|
|
41
34
|
|
|
42
35
|
|
|
43
36
|
def _installed_skill_destinations() -> list[Path]:
|
|
37
|
+
"""Return default install paths for the codeforerunner skill across supported agents."""
|
|
44
38
|
home = Path(os.path.expanduser("~"))
|
|
45
39
|
return [
|
|
46
40
|
home / ".codex/skills/codeforerunner/SKILL.md",
|
|
@@ -49,10 +43,12 @@ def _installed_skill_destinations() -> list[Path]:
|
|
|
49
43
|
|
|
50
44
|
|
|
51
45
|
def _installed_marketplace_destination() -> Path:
|
|
46
|
+
"""Return default install path for the Codex marketplace manifest."""
|
|
52
47
|
return Path(os.path.expanduser("~")) / ".codex/marketplaces/codeforerunner.json"
|
|
53
48
|
|
|
54
49
|
|
|
55
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."""
|
|
56
52
|
# L3: unique name prevents stale cached module on repeated calls
|
|
57
53
|
unique_name = f"{module_name}_{uuid.uuid4().hex}"
|
|
58
54
|
script_path = repo / relpath
|
|
@@ -66,6 +62,7 @@ def _load_script_module(repo: Path, relpath: str, module_name: str):
|
|
|
66
62
|
|
|
67
63
|
|
|
68
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."""
|
|
69
66
|
if not run_scripts:
|
|
70
67
|
return [
|
|
71
68
|
Finding(
|
|
@@ -118,6 +115,7 @@ def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Find
|
|
|
118
115
|
|
|
119
116
|
|
|
120
117
|
def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
118
|
+
"""Validate the Codex marketplace manifest using the repo validation script."""
|
|
121
119
|
if not run_scripts:
|
|
122
120
|
return [
|
|
123
121
|
Finding(
|
|
@@ -145,6 +143,7 @@ def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Find
|
|
|
145
143
|
|
|
146
144
|
|
|
147
145
|
def _check_installed_destinations(repo: Path) -> list[Finding]:
|
|
146
|
+
"""Check whether installed skill and marketplace files are present and managed."""
|
|
148
147
|
findings: list[Finding] = []
|
|
149
148
|
|
|
150
149
|
for dest in _installed_skill_destinations():
|
|
@@ -229,6 +228,7 @@ def _check_installed_destinations(repo: Path) -> list[Finding]:
|
|
|
229
228
|
|
|
230
229
|
|
|
231
230
|
def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
231
|
+
"""Try parsing forerunner.config.yaml; report error finding on ConfigError."""
|
|
232
232
|
cfg_path = repo / CONFIG_FILENAME
|
|
233
233
|
if not cfg_path.is_file():
|
|
234
234
|
return [
|
|
@@ -245,89 +245,6 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
|
245
245
|
return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
|
|
246
246
|
|
|
247
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
|
-
|
|
261
|
-
def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
262
|
-
from codeforerunner.providers.ollama import is_available as _ollama_available
|
|
263
|
-
|
|
264
|
-
cfg_path = repo / CONFIG_FILENAME
|
|
265
|
-
if not cfg_path.is_file():
|
|
266
|
-
if _ollama_available():
|
|
267
|
-
return [
|
|
268
|
-
Finding(
|
|
269
|
-
"ok",
|
|
270
|
-
"provider-api-key",
|
|
271
|
-
"no config; Ollama running — generate will use local mode automatically",
|
|
272
|
-
)
|
|
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
|
-
]
|
|
282
|
-
return [
|
|
283
|
-
Finding(
|
|
284
|
-
"ok",
|
|
285
|
-
"provider-api-key",
|
|
286
|
-
f"no {CONFIG_FILENAME}; set an API key in config, start Ollama, or use skill mode (`forerunner generate --prompt-only`)",
|
|
287
|
-
)
|
|
288
|
-
]
|
|
289
|
-
try:
|
|
290
|
-
cfg = load_from_repo(repo)
|
|
291
|
-
except ConfigError:
|
|
292
|
-
# config-loadable check will surface this; skip here
|
|
293
|
-
return [
|
|
294
|
-
Finding(
|
|
295
|
-
"ok",
|
|
296
|
-
"provider-api-key",
|
|
297
|
-
"config unparseable; skipped (see config-loadable)",
|
|
298
|
-
)
|
|
299
|
-
]
|
|
300
|
-
if cfg is None: # pragma: no cover - defensive
|
|
301
|
-
return [
|
|
302
|
-
Finding(
|
|
303
|
-
"ok",
|
|
304
|
-
"provider-api-key",
|
|
305
|
-
f"no {CONFIG_FILENAME}; provider key not checked",
|
|
306
|
-
)
|
|
307
|
-
]
|
|
308
|
-
provider = cfg.provider
|
|
309
|
-
if provider == "ollama":
|
|
310
|
-
return [
|
|
311
|
-
Finding(
|
|
312
|
-
"ok",
|
|
313
|
-
"provider-api-key",
|
|
314
|
-
"running in local mode (Ollama; no API key needed)",
|
|
315
|
-
)
|
|
316
|
-
]
|
|
317
|
-
env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")
|
|
318
|
-
if os.environ.get(env_var):
|
|
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.
|
|
322
|
-
return [
|
|
323
|
-
Finding(
|
|
324
|
-
"warn",
|
|
325
|
-
"provider-api-key",
|
|
326
|
-
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run",
|
|
327
|
-
)
|
|
328
|
-
]
|
|
329
|
-
|
|
330
|
-
|
|
331
248
|
_STARTER_CONFIG = """\
|
|
332
249
|
# forerunner.config.yaml — generated by `forerunner doctor --fix`
|
|
333
250
|
# See https://github.com/derek-palmer/codeforerunner for docs.
|
|
@@ -359,7 +276,6 @@ def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
|
359
276
|
findings.extend(_check_codex_marketplace(repo, run_scripts=run_scripts))
|
|
360
277
|
findings.extend(_check_installed_destinations(repo))
|
|
361
278
|
findings.extend(_check_config_loadable(repo))
|
|
362
|
-
findings.extend(_check_provider_api_key(repo))
|
|
363
279
|
return findings
|
|
364
280
|
|
|
365
281
|
|
codeforerunner/installer.py
CHANGED
|
@@ -48,6 +48,7 @@ class Target:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _home() -> Path:
|
|
51
|
+
"""Return the current user's home directory as a Path."""
|
|
51
52
|
return Path(os.path.expanduser("~"))
|
|
52
53
|
|
|
53
54
|
|
|
@@ -164,10 +165,12 @@ def extract_frontmatter(text: str) -> str:
|
|
|
164
165
|
|
|
165
166
|
|
|
166
167
|
def _hash(s: str) -> str:
|
|
168
|
+
"""Return SHA-256 hex digest of a UTF-8 encoded string."""
|
|
167
169
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
168
170
|
|
|
169
171
|
|
|
170
172
|
def _hash_bytes(b: bytes) -> str:
|
|
173
|
+
"""Return SHA-256 hex digest of raw bytes."""
|
|
171
174
|
return hashlib.sha256(b).hexdigest()
|
|
172
175
|
|
|
173
176
|
|
|
@@ -383,6 +386,7 @@ def add_subparser(sub: argparse._SubParsersAction) -> None:
|
|
|
383
386
|
|
|
384
387
|
|
|
385
388
|
def _cli_entry(args: argparse.Namespace) -> int:
|
|
389
|
+
"""Dispatch `forerunner install` subcommand from parsed CLI args."""
|
|
386
390
|
root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
387
391
|
|
|
388
392
|
if getattr(args, "all", False):
|
codeforerunner/mcp_server.py
CHANGED
|
@@ -19,6 +19,7 @@ SERVER_VERSION = _pkg_version
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _list_tasks(prompts_root: Path) -> list[Path]:
|
|
22
|
+
"""Return sorted list of task *.md paths under prompts_root/tasks/."""
|
|
22
23
|
tasks_dir = prompts_root / "tasks"
|
|
23
24
|
if not tasks_dir.is_dir():
|
|
24
25
|
return []
|
|
@@ -37,6 +38,7 @@ def _description_for(task_path: Path) -> str:
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _tools(prompts_root: Path) -> list[dict[str, Any]]:
|
|
41
|
+
"""Build MCP tools/list payload from all task files in prompts_root."""
|
|
40
42
|
return [
|
|
41
43
|
{
|
|
42
44
|
"name": p.stem,
|
|
@@ -48,10 +50,12 @@ def _tools(prompts_root: Path) -> list[dict[str, Any]]:
|
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
def _ok(req_id: Any, result: Any) -> dict[str, Any]:
|
|
53
|
+
"""Return a JSON-RPC 2.0 success response."""
|
|
51
54
|
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def _err(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
58
|
+
"""Return a JSON-RPC 2.0 error response."""
|
|
55
59
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
56
60
|
|
|
57
61
|
|
|
@@ -59,6 +63,7 @@ SCAN_EXEMPT_TOOLS = frozenset({"init-agent-onboarding", "scan"})
|
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> dict[str, Any] | None:
|
|
66
|
+
"""Dispatch a single JSON-RPC message; return response dict or None for notifications."""
|
|
62
67
|
method = msg.get("method")
|
|
63
68
|
req_id = msg.get("id")
|
|
64
69
|
params = msg.get("params") or {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Task: Refresh All Documentation
|
|
2
|
+
|
|
3
|
+
Runs a full documentation refresh cycle: scan, check staleness, then generate or update every stale or missing doc in one pass.
|
|
4
|
+
|
|
5
|
+
This prompt is the batch form (all bundles concatenated). When running via the `/forerunner-refresh` skill, the agent calls `forerunner doc <task>` for each step individually so it can process each result before moving to the next.
|
|
6
|
+
|
|
7
|
+
## Steps (execute in order)
|
|
8
|
+
|
|
9
|
+
1. **Scan** — Execute the scan task bundle. Capture the YAML output. All downstream tasks depend on it.
|
|
10
|
+
2. **Check** — Execute the check task bundle using the scan result. Identify every doc with `STALE` or `MISSING` status.
|
|
11
|
+
3. **Generate / update** — For each stale or missing doc, run the corresponding task bundle in this order:
|
|
12
|
+
`readme` → `api-docs` → `stack-docs` → `diagrams` → `flows` → `version-audit` → `audit`
|
|
13
|
+
Skip any task whose check status is `CURRENT`.
|
|
14
|
+
Note: `changelog` and `review` are on-demand tasks excluded from automated refresh.
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
- The scan result from step 1 is the input to all downstream tasks.
|
|
19
|
+
- The check report from step 2 determines which tasks run.
|
|
20
|
+
- Stop and report if scan fails (non-zero exit or empty output).
|
|
21
|
+
- Write each artifact to its task-defined output path.
|
|
22
|
+
- Append a `## Gaps` section to any doc where evidence is insufficient — never silently omit content.
|
|
23
|
+
- Report a summary of what was updated, skipped, and any gaps found.
|