codeforerunner 0.4.1__py3-none-any.whl → 0.4.3__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 +2 -1
- codeforerunner/check.py +2 -0
- codeforerunner/cli.py +67 -28
- codeforerunner/config.py +19 -11
- codeforerunner/doctor.py +77 -25
- codeforerunner/installer.py +18 -2
- codeforerunner/mcp_server.py +18 -8
- codeforerunner/providers/__init__.py +1 -0
- codeforerunner/providers/anthropic.py +6 -2
- codeforerunner/providers/base.py +10 -2
- codeforerunner/providers/google.py +8 -2
- codeforerunner/providers/ollama.py +30 -2
- codeforerunner/providers/openai.py +6 -2
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.dist-info}/METADATA +2 -2
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.dist-info}/RECORD +20 -20
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.dist-info}/WHEEL +0 -0
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.dist-info}/entry_points.txt +0 -0
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.dist-info}/licenses/LICENSE.md +0 -0
- {codeforerunner-0.4.1.dist-info → codeforerunner-0.4.3.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
|
@@ -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
|
|
codeforerunner/check.py
CHANGED
codeforerunner/cli.py
CHANGED
|
@@ -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"):
|
codeforerunner/config.py
CHANGED
|
@@ -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")),
|
codeforerunner/doctor.py
CHANGED
|
@@ -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
|
|
codeforerunner/installer.py
CHANGED
|
@@ -41,6 +41,8 @@ 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
|
|
|
@@ -50,6 +52,7 @@ def _home() -> Path:
|
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def resolve_target(agent: str, override: Path | None) -> Target:
|
|
55
|
+
"""Return the default install Target for the given agent, or use override path."""
|
|
53
56
|
if agent == "generic":
|
|
54
57
|
if override is None:
|
|
55
58
|
raise ValueError("generic target requires --path PATH")
|
|
@@ -95,6 +98,7 @@ def install_all_skills(
|
|
|
95
98
|
src_path = repo_root / "plugins" / "codeforerunner" / "skills" / slug / "SKILL.md"
|
|
96
99
|
if not src_path.is_file():
|
|
97
100
|
print(f"warning: skill source not found: {src_path}", file=err)
|
|
101
|
+
any_error = True
|
|
98
102
|
continue
|
|
99
103
|
try:
|
|
100
104
|
target = resolve_skill_target(agent, slug)
|
|
@@ -116,11 +120,16 @@ def install_all_skills(
|
|
|
116
120
|
print(f"{prefix}{action}: {dest}", file=out)
|
|
117
121
|
if not check_only:
|
|
118
122
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
123
|
+
try:
|
|
124
|
+
dest.write_bytes(src_path.read_bytes())
|
|
125
|
+
except OSError as e:
|
|
126
|
+
print(f"error: failed to write {dest}: {e}", file=err)
|
|
127
|
+
any_error = True
|
|
120
128
|
return EXIT_OK if not any_error else EXIT_BODY_MISMATCH
|
|
121
129
|
|
|
122
130
|
|
|
123
131
|
def resolve_marketplace_target(agent: str, override: Path | None) -> Target:
|
|
132
|
+
"""Return the marketplace install Target for the given agent, or use override path."""
|
|
124
133
|
if agent == "generic":
|
|
125
134
|
if override is None:
|
|
126
135
|
raise ValueError("generic marketplace target requires --path PATH")
|
|
@@ -176,6 +185,7 @@ def render(source_text: str, dest_existing: str | None, agent: str) -> str:
|
|
|
176
185
|
|
|
177
186
|
|
|
178
187
|
def find_markers(text: str) -> tuple[int, int] | None:
|
|
188
|
+
"""Return (start, end) byte offsets of the managed region, or None if absent."""
|
|
179
189
|
a = text.find(MARKER_BEGIN)
|
|
180
190
|
if a < 0:
|
|
181
191
|
return None
|
|
@@ -188,7 +198,8 @@ def find_markers(text: str) -> tuple[int, int] | None:
|
|
|
188
198
|
def overlay(dest_text: str, source_body: str) -> str:
|
|
189
199
|
"""Replace managed region in-place. Caller has verified markers exist."""
|
|
190
200
|
span = find_markers(dest_text)
|
|
191
|
-
|
|
201
|
+
if span is None:
|
|
202
|
+
raise RuntimeError("overlay: span is None — this is a bug")
|
|
192
203
|
a, b = span
|
|
193
204
|
managed = f"{MARKER_BEGIN}\n{source_body}\n{MARKER_END}"
|
|
194
205
|
return dest_text[:a] + managed + dest_text[b:]
|
|
@@ -196,12 +207,15 @@ def overlay(dest_text: str, source_body: str) -> str:
|
|
|
196
207
|
|
|
197
208
|
@dataclass
|
|
198
209
|
class Plan:
|
|
210
|
+
"""Pending install action computed by plan_install or plan_marketplace."""
|
|
211
|
+
|
|
199
212
|
action: str # "create" | "update" | "skip" | "abort"
|
|
200
213
|
reason: str
|
|
201
214
|
target: Target
|
|
202
215
|
new_content: str | None = None
|
|
203
216
|
|
|
204
217
|
def write(self) -> None:
|
|
218
|
+
"""Execute the plan: create or update the destination file."""
|
|
205
219
|
if self.action in ("skip", "abort"):
|
|
206
220
|
return
|
|
207
221
|
assert self.new_content is not None
|
|
@@ -300,6 +314,7 @@ def install(
|
|
|
300
314
|
out=None,
|
|
301
315
|
err=None,
|
|
302
316
|
) -> int:
|
|
317
|
+
"""Run one install operation (skill or marketplace). Returns an EXIT_* code."""
|
|
303
318
|
out = out or sys.stdout
|
|
304
319
|
err = err or sys.stderr
|
|
305
320
|
|
|
@@ -350,6 +365,7 @@ def install(
|
|
|
350
365
|
|
|
351
366
|
|
|
352
367
|
def add_subparser(sub: argparse._SubParsersAction) -> None:
|
|
368
|
+
"""Register the `forerunner install` subcommand onto *sub*."""
|
|
353
369
|
p = sub.add_parser("install", help="install skill(s) into agent-specific directories (D.installer)")
|
|
354
370
|
p.add_argument("agent", choices=["codex", "claude", "generic"], nargs="?",
|
|
355
371
|
help="target agent (omit with --all to install to all detected agents)")
|
codeforerunner/mcp_server.py
CHANGED
|
@@ -27,11 +27,12 @@ def _list_tasks(prompts_root: Path) -> list[Path]:
|
|
|
27
27
|
|
|
28
28
|
def _description_for(task_path: Path) -> str:
|
|
29
29
|
"""First non-empty markdown line, stripped of leading '#' and whitespace."""
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
with task_path.open(encoding="utf-8") as f:
|
|
31
|
+
for raw in f:
|
|
32
|
+
line = raw.strip()
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
return line.lstrip("#").strip()
|
|
35
36
|
return task_path.stem
|
|
36
37
|
|
|
37
38
|
|
|
@@ -68,6 +69,7 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
68
69
|
return None
|
|
69
70
|
|
|
70
71
|
if method == "initialize":
|
|
72
|
+
state["initialized"] = True
|
|
71
73
|
return _ok(
|
|
72
74
|
req_id,
|
|
73
75
|
{
|
|
@@ -77,13 +79,19 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
77
79
|
},
|
|
78
80
|
)
|
|
79
81
|
|
|
82
|
+
if not state.get("initialized"):
|
|
83
|
+
return _err(req_id, -32002, "Server not initialized")
|
|
84
|
+
|
|
80
85
|
if method == "tools/list":
|
|
81
86
|
return _ok(req_id, {"tools": _tools(prompts_root)})
|
|
82
87
|
|
|
83
88
|
if method == "tools/call":
|
|
84
89
|
name = params.get("name")
|
|
90
|
+
if not isinstance(name, str) or "/" in name or "\\" in name or ".." in name:
|
|
91
|
+
return _err(req_id, -32602, f"invalid tool name: {name!r}")
|
|
85
92
|
task_path = prompts_root / "tasks" / f"{name}.md"
|
|
86
|
-
|
|
93
|
+
tasks_root = (prompts_root / "tasks").resolve()
|
|
94
|
+
if not task_path.resolve().is_relative_to(tasks_root) or not task_path.is_file():
|
|
87
95
|
return _err(req_id, -32602, f"unknown tool: {name!r}")
|
|
88
96
|
if name not in SCAN_EXEMPT_TOOLS and not state.get("scan_called"):
|
|
89
97
|
return _err(
|
|
@@ -106,7 +114,8 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
106
114
|
|
|
107
115
|
|
|
108
116
|
def serve(prompts_root: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdout, stderr=sys.stderr) -> int:
|
|
109
|
-
|
|
117
|
+
"""Run the JSON-RPC 2.0 MCP server loop over *stdin*/*stdout* until EOF."""
|
|
118
|
+
state: dict[str, Any] = {"scan_called": False, "initialized": False}
|
|
110
119
|
for raw in stdin:
|
|
111
120
|
line = raw.strip()
|
|
112
121
|
if not line:
|
|
@@ -133,6 +142,7 @@ def serve(prompts_root: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdou
|
|
|
133
142
|
|
|
134
143
|
|
|
135
144
|
def main(argv: list[str] | None = None) -> int:
|
|
145
|
+
"""Locate prompts root and start the MCP server; returns 2 if prompts not found."""
|
|
136
146
|
try:
|
|
137
147
|
prompts_root = find_prompts_root()
|
|
138
148
|
except FileNotFoundError as e:
|
|
@@ -141,5 +151,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
141
151
|
return serve(prompts_root)
|
|
142
152
|
|
|
143
153
|
|
|
144
|
-
if __name__ == "__main__":
|
|
154
|
+
if __name__ == "__main__": # pragma: no cover
|
|
145
155
|
raise SystemExit(main())
|
|
@@ -30,6 +30,7 @@ REGISTRY: dict[str, type] = {
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def get(name: str) -> type:
|
|
33
|
+
"""Return the provider class for *name*, or raise ProviderError if unknown."""
|
|
33
34
|
if name not in REGISTRY:
|
|
34
35
|
raise ProviderError(
|
|
35
36
|
f"unknown provider '{name}' (expected one of {sorted(REGISTRY)})"
|
|
@@ -11,6 +11,8 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class AnthropicProvider:
|
|
14
|
+
"""Anthropic Messages API provider using stdlib HTTP."""
|
|
15
|
+
|
|
14
16
|
name = "anthropic"
|
|
15
17
|
default_env_var = "ANTHROPIC_API_KEY"
|
|
16
18
|
default_model = "claude-opus-4-7"
|
|
@@ -24,6 +26,7 @@ class AnthropicProvider:
|
|
|
24
26
|
model: str | None = None,
|
|
25
27
|
api_key: str | None = None,
|
|
26
28
|
) -> CompletionResult:
|
|
29
|
+
"""Send *prompt* to the Anthropic Messages API and return the full response."""
|
|
27
30
|
if not api_key:
|
|
28
31
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
29
32
|
model = model or self.default_model
|
|
@@ -45,7 +48,7 @@ class AnthropicProvider:
|
|
|
45
48
|
},
|
|
46
49
|
)
|
|
47
50
|
try:
|
|
48
|
-
with urllib.request.urlopen(req) as resp:
|
|
51
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
49
52
|
raw = resp.read()
|
|
50
53
|
except urllib.error.HTTPError as e:
|
|
51
54
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -68,6 +71,7 @@ class AnthropicProvider:
|
|
|
68
71
|
model: str | None = None,
|
|
69
72
|
api_key: str | None = None,
|
|
70
73
|
) -> Iterator[str]:
|
|
74
|
+
"""Yield text chunks from the Anthropic streaming Messages API."""
|
|
71
75
|
if not api_key:
|
|
72
76
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
73
77
|
model = model or self.default_model
|
|
@@ -90,7 +94,7 @@ class AnthropicProvider:
|
|
|
90
94
|
},
|
|
91
95
|
)
|
|
92
96
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
97
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
98
|
except urllib.error.HTTPError as e:
|
|
95
99
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
100
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
codeforerunner/providers/base.py
CHANGED
|
@@ -8,12 +8,16 @@ from typing import Iterator, Protocol
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
10
10
|
class CompletionResult:
|
|
11
|
+
"""Completed text response returned by a provider."""
|
|
12
|
+
|
|
11
13
|
text: str
|
|
12
14
|
model: str
|
|
13
15
|
usage: dict | None = None # provider-reported token counts; None if unknown
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class Provider(Protocol):
|
|
19
|
+
"""Structural protocol that all LLM provider classes must satisfy."""
|
|
20
|
+
|
|
17
21
|
name: str
|
|
18
22
|
default_env_var: str # e.g. "ANTHROPIC_API_KEY"
|
|
19
23
|
default_model: str # provider's recommended default
|
|
@@ -24,7 +28,9 @@ class Provider(Protocol):
|
|
|
24
28
|
prompt: str,
|
|
25
29
|
model: str | None = None,
|
|
26
30
|
api_key: str | None = None,
|
|
27
|
-
) -> CompletionResult:
|
|
31
|
+
) -> CompletionResult:
|
|
32
|
+
"""Send *prompt* and return the full completion result."""
|
|
33
|
+
...
|
|
28
34
|
|
|
29
35
|
def stream(
|
|
30
36
|
self,
|
|
@@ -32,7 +38,9 @@ class Provider(Protocol):
|
|
|
32
38
|
prompt: str,
|
|
33
39
|
model: str | None = None,
|
|
34
40
|
api_key: str | None = None,
|
|
35
|
-
) -> Iterator[str]:
|
|
41
|
+
) -> Iterator[str]:
|
|
42
|
+
"""Yield text chunks from *prompt* as they arrive from the provider."""
|
|
43
|
+
...
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
class ProviderError(Exception):
|
|
@@ -12,10 +12,14 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class GoogleProvider:
|
|
15
|
+
"""Google Gemini generateContent provider using stdlib HTTP."""
|
|
16
|
+
|
|
15
17
|
name = "google"
|
|
16
18
|
default_env_var = "GOOGLE_API_KEY"
|
|
17
19
|
default_model = "gemini-2.5-pro"
|
|
18
20
|
|
|
21
|
+
# Google REST API requires the key in the URL query string — this is the documented
|
|
22
|
+
# mechanism and cannot be changed. Be aware the key may appear in proxy/server logs.
|
|
19
23
|
endpoint_template = (
|
|
20
24
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
21
25
|
)
|
|
@@ -31,6 +35,7 @@ class GoogleProvider:
|
|
|
31
35
|
model: str | None = None,
|
|
32
36
|
api_key: str | None = None,
|
|
33
37
|
) -> CompletionResult:
|
|
38
|
+
"""Send *prompt* to the Gemini generateContent endpoint and return the full response."""
|
|
34
39
|
if not api_key:
|
|
35
40
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
36
41
|
model = model or self.default_model
|
|
@@ -48,7 +53,7 @@ class GoogleProvider:
|
|
|
48
53
|
headers={"content-type": "application/json"},
|
|
49
54
|
)
|
|
50
55
|
try:
|
|
51
|
-
with urllib.request.urlopen(req) as resp:
|
|
56
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
52
57
|
raw = resp.read()
|
|
53
58
|
except urllib.error.HTTPError as e:
|
|
54
59
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -73,6 +78,7 @@ class GoogleProvider:
|
|
|
73
78
|
model: str | None = None,
|
|
74
79
|
api_key: str | None = None,
|
|
75
80
|
) -> Iterator[str]:
|
|
81
|
+
"""Yield text chunks from the Gemini streaming generateContent endpoint."""
|
|
76
82
|
if not api_key:
|
|
77
83
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
78
84
|
model = model or self.default_model
|
|
@@ -90,7 +96,7 @@ class GoogleProvider:
|
|
|
90
96
|
headers={"content-type": "application/json"},
|
|
91
97
|
)
|
|
92
98
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
99
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
100
|
except urllib.error.HTTPError as e:
|
|
95
101
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
102
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
8
9
|
import urllib.request
|
|
9
10
|
from typing import Iterator
|
|
10
11
|
|
|
@@ -12,11 +13,32 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
12
13
|
|
|
13
14
|
DEFAULT_HOST = "http://localhost:11434"
|
|
14
15
|
|
|
16
|
+
_SAFE_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_ollama_base(base: str) -> None:
|
|
20
|
+
"""Reject URLs that look like cloud metadata endpoints or use unexpected schemes."""
|
|
21
|
+
try:
|
|
22
|
+
parsed = urllib.parse.urlparse(base)
|
|
23
|
+
except Exception as e: # pragma: no cover - urlparse never raises
|
|
24
|
+
raise ValueError(f"OLLAMA_HOST: invalid URL: {e}") from e
|
|
25
|
+
if parsed.scheme not in ("http", "https"):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"OLLAMA_HOST: scheme must be http or https, got {parsed.scheme!r}"
|
|
28
|
+
)
|
|
29
|
+
host = parsed.hostname or ""
|
|
30
|
+
if "169.254." in host:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"OLLAMA_HOST: refusing connection to link-local address {host!r} "
|
|
33
|
+
"(looks like a cloud metadata endpoint)"
|
|
34
|
+
)
|
|
35
|
+
|
|
15
36
|
|
|
16
37
|
def is_available(host: str | None = None) -> bool:
|
|
17
38
|
"""Return True if an Ollama instance is reachable at the configured host."""
|
|
18
39
|
base = (host or os.environ.get("OLLAMA_HOST") or DEFAULT_HOST).rstrip("/")
|
|
19
40
|
try:
|
|
41
|
+
_validate_ollama_base(base)
|
|
20
42
|
urllib.request.urlopen(f"{base}/api/tags", timeout=2)
|
|
21
43
|
return True
|
|
22
44
|
except Exception:
|
|
@@ -24,6 +46,8 @@ def is_available(host: str | None = None) -> bool:
|
|
|
24
46
|
|
|
25
47
|
|
|
26
48
|
class OllamaProvider:
|
|
49
|
+
"""Ollama local provider using stdlib HTTP."""
|
|
50
|
+
|
|
27
51
|
name = "ollama"
|
|
28
52
|
default_env_var = "OLLAMA_HOST"
|
|
29
53
|
default_model = "llama3"
|
|
@@ -35,9 +59,11 @@ class OllamaProvider:
|
|
|
35
59
|
model: str | None = None,
|
|
36
60
|
api_key: str | None = None,
|
|
37
61
|
) -> CompletionResult:
|
|
62
|
+
"""Send *prompt* to the Ollama /api/generate endpoint and return the full response."""
|
|
38
63
|
# api_key is interpreted as a base URL override; fall back to env then default.
|
|
39
64
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
40
65
|
base = base.rstrip("/")
|
|
66
|
+
_validate_ollama_base(base)
|
|
41
67
|
model = model or self.default_model
|
|
42
68
|
url = f"{base}/api/generate"
|
|
43
69
|
body = json.dumps(
|
|
@@ -50,7 +76,7 @@ class OllamaProvider:
|
|
|
50
76
|
headers={"content-type": "application/json"},
|
|
51
77
|
)
|
|
52
78
|
try:
|
|
53
|
-
with urllib.request.urlopen(req) as resp:
|
|
79
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
54
80
|
raw = resp.read()
|
|
55
81
|
except urllib.error.HTTPError as e:
|
|
56
82
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -73,8 +99,10 @@ class OllamaProvider:
|
|
|
73
99
|
model: str | None = None,
|
|
74
100
|
api_key: str | None = None,
|
|
75
101
|
) -> Iterator[str]:
|
|
102
|
+
"""Yield text chunks from the Ollama /api/generate streaming endpoint."""
|
|
76
103
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
77
104
|
base = base.rstrip("/")
|
|
105
|
+
_validate_ollama_base(base)
|
|
78
106
|
model = model or self.default_model
|
|
79
107
|
url = f"{base}/api/generate"
|
|
80
108
|
body = json.dumps(
|
|
@@ -87,7 +115,7 @@ class OllamaProvider:
|
|
|
87
115
|
headers={"content-type": "application/json"},
|
|
88
116
|
)
|
|
89
117
|
try:
|
|
90
|
-
resp = urllib.request.urlopen(req)
|
|
118
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
91
119
|
except urllib.error.HTTPError as e:
|
|
92
120
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
93
121
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -11,6 +11,8 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class OpenAIProvider:
|
|
14
|
+
"""OpenAI chat completions provider using stdlib HTTP."""
|
|
15
|
+
|
|
14
16
|
name = "openai"
|
|
15
17
|
default_env_var = "OPENAI_API_KEY"
|
|
16
18
|
default_model = "gpt-4o"
|
|
@@ -24,6 +26,7 @@ class OpenAIProvider:
|
|
|
24
26
|
model: str | None = None,
|
|
25
27
|
api_key: str | None = None,
|
|
26
28
|
) -> CompletionResult:
|
|
29
|
+
"""Send *prompt* to the OpenAI chat completions endpoint and return the full response."""
|
|
27
30
|
if not api_key:
|
|
28
31
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
29
32
|
model = model or self.default_model
|
|
@@ -43,7 +46,7 @@ class OpenAIProvider:
|
|
|
43
46
|
},
|
|
44
47
|
)
|
|
45
48
|
try:
|
|
46
|
-
with urllib.request.urlopen(req) as resp:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
47
50
|
raw = resp.read()
|
|
48
51
|
except urllib.error.HTTPError as e:
|
|
49
52
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -66,6 +69,7 @@ class OpenAIProvider:
|
|
|
66
69
|
model: str | None = None,
|
|
67
70
|
api_key: str | None = None,
|
|
68
71
|
) -> Iterator[str]:
|
|
72
|
+
"""Yield text chunks from the OpenAI streaming chat completions endpoint."""
|
|
69
73
|
if not api_key:
|
|
70
74
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
71
75
|
model = model or self.default_model
|
|
@@ -86,7 +90,7 @@ class OpenAIProvider:
|
|
|
86
90
|
},
|
|
87
91
|
)
|
|
88
92
|
try:
|
|
89
|
-
resp = urllib.request.urlopen(req)
|
|
93
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
90
94
|
except urllib.error.HTTPError as e:
|
|
91
95
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
92
96
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -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.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
codeforerunner/__init__.py,sha256=
|
|
2
|
-
codeforerunner/bundle.py,sha256=
|
|
3
|
-
codeforerunner/check.py,sha256=
|
|
4
|
-
codeforerunner/cli.py,sha256=
|
|
5
|
-
codeforerunner/config.py,sha256=
|
|
6
|
-
codeforerunner/doctor.py,sha256=
|
|
7
|
-
codeforerunner/installer.py,sha256=
|
|
8
|
-
codeforerunner/mcp_server.py,sha256=
|
|
1
|
+
codeforerunner/__init__.py,sha256=lDClMqfAXApyQaPIWGp01yvS0zWJC-7sXTCRKPqit9M,292
|
|
2
|
+
codeforerunner/bundle.py,sha256=2GByXu-oFbplWpCJuGnF_qobcHqv8YjyTZX1BU4WoJM,2053
|
|
3
|
+
codeforerunner/check.py,sha256=HhhTuoFUiT2hjeK6JwjZknplJf43GqdSwZnz_V7G8n0,8272
|
|
4
|
+
codeforerunner/cli.py,sha256=yWM72NR8roIM63zBFe3ibzxkDVHpD9X_-HueAdnNvo0,11442
|
|
5
|
+
codeforerunner/config.py,sha256=CC9JhlKv4L3b7cRc4BiB9PHKVRoibbkBGHRBWjoZKpM,6454
|
|
6
|
+
codeforerunner/doctor.py,sha256=GjMJpjXqrn4nCtkbFu22HvApR5dqLp0dMSDlXrxJ1Ys,13164
|
|
7
|
+
codeforerunner/installer.py,sha256=SPBr6Tmfpy3SmKaGQf-P_AhGvjbBz3lRNjGdU3-H-xg,14279
|
|
8
|
+
codeforerunner/mcp_server.py,sha256=DtlR1e9JsRabyB42tboq7hC3yate8M5UoxyAYUQbn0M,5260
|
|
9
9
|
codeforerunner/prompts/partials/context-format.md,sha256=WNfkr4kf2Awj0R8wLOrFotEiYCe6hfKTq5eA3Rt5_Xw,817
|
|
10
10
|
codeforerunner/prompts/partials/output-rules.md,sha256=vfIAX-ImxCa-MVAeNH896uSIO7-cKbJd0KohkgHIiD8,1731
|
|
11
11
|
codeforerunner/prompts/partials/stack-hints.md,sha256=8E2qELhk-hve2ULSdmiFK48LE4Aprhmuasqr6A1K2QU,2001
|
|
@@ -22,15 +22,15 @@ codeforerunner/prompts/tasks/review.md,sha256=IRdIXAKvv0JMOE5WtrnlO1Cd4LHXtcJqb1
|
|
|
22
22
|
codeforerunner/prompts/tasks/scan.md,sha256=hYXf-IL1kgpBPHJapRrwTu88cLZ7y3bCmAq9HY0yG3U,2300
|
|
23
23
|
codeforerunner/prompts/tasks/stack-docs.md,sha256=Dy-JSXpSmHSyhR5shQBXKa_F0PqnjPcmtljthYZpaiM,1923
|
|
24
24
|
codeforerunner/prompts/tasks/version-audit.md,sha256=oK-pcoxt_VcvDOlj1Sz9OlEhXlcViLPn54r-qP5WfiA,5833
|
|
25
|
-
codeforerunner/providers/__init__.py,sha256=
|
|
26
|
-
codeforerunner/providers/anthropic.py,sha256=
|
|
27
|
-
codeforerunner/providers/base.py,sha256=
|
|
28
|
-
codeforerunner/providers/google.py,sha256=
|
|
29
|
-
codeforerunner/providers/ollama.py,sha256=
|
|
30
|
-
codeforerunner/providers/openai.py,sha256=
|
|
31
|
-
codeforerunner-0.4.
|
|
32
|
-
codeforerunner-0.4.
|
|
33
|
-
codeforerunner-0.4.
|
|
34
|
-
codeforerunner-0.4.
|
|
35
|
-
codeforerunner-0.4.
|
|
36
|
-
codeforerunner-0.4.
|
|
25
|
+
codeforerunner/providers/__init__.py,sha256=PYr0Za1gHgAFRExUormDZZlsStI0RUheCgBj71I8b8w,1103
|
|
26
|
+
codeforerunner/providers/anthropic.py,sha256=CYaSDtxIfJR-Z_F17_OfPPWHGEH7l61mQk0fZFsUFzI,4300
|
|
27
|
+
codeforerunner/providers/base.py,sha256=bmOxnzWGGMhIWwuvIm9YQA_tPJyzXoIo1ciGzsDANz4,1220
|
|
28
|
+
codeforerunner/providers/google.py,sha256=_cnidRymoCS32xUXibDTQ4nncwg0k0qI0YvPab2G64E,4434
|
|
29
|
+
codeforerunner/providers/ollama.py,sha256=9ZU110g5qzEP1chC4MfG6zT2pgfl56jREL_dxt25vCw,5032
|
|
30
|
+
codeforerunner/providers/openai.py,sha256=B-c551-NOGazlVVmP6d-hoeH7GKN4-eTEOuhcvhootA,4097
|
|
31
|
+
codeforerunner-0.4.3.dist-info/licenses/LICENSE.md,sha256=iIhmJHib6GbdjcwiDMM-npiNRf3XgASom1WsOJivEdc,2915
|
|
32
|
+
codeforerunner-0.4.3.dist-info/METADATA,sha256=n74THMcrVT-pgL1ZNKljmnVVE1wxu5XeP_O7LvNUlKs,9873
|
|
33
|
+
codeforerunner-0.4.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
codeforerunner-0.4.3.dist-info/entry_points.txt,sha256=3p8BbPlq-wfcXk42tsweKePRaGlZ1WXho1gOkuZGyIQ,55
|
|
35
|
+
codeforerunner-0.4.3.dist-info/top_level.txt,sha256=pV1rt0-NIpNEotKXpL_sF2060DHr-_0F86LWhUlvXis,15
|
|
36
|
+
codeforerunner-0.4.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|