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