codeforerunner 0.4.0__py3-none-any.whl → 0.4.2__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 +2 -1
- codeforerunner/cli.py +60 -27
- codeforerunner/config.py +11 -9
- codeforerunner/doctor.py +74 -25
- codeforerunner/installer.py +8 -2
- codeforerunner/mcp_server.py +15 -7
- codeforerunner/providers/anthropic.py +2 -2
- codeforerunner/providers/google.py +4 -2
- codeforerunner/providers/ollama.py +26 -2
- codeforerunner/providers/openai.py +2 -2
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/METADATA +2 -2
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/RECORD +16 -16
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/WHEEL +0 -0
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/entry_points.txt +0 -0
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/licenses/LICENSE.md +0 -0
- {codeforerunner-0.4.0.dist-info → codeforerunner-0.4.2.dist-info}/top_level.txt +0 -0
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/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
|
|
|
@@ -104,13 +112,27 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
|
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def cmd_generate(args: argparse.Namespace) -> int:
|
|
107
|
-
"""Resolve the bundle for <task> and send it to the configured provider.
|
|
115
|
+
"""Resolve the bundle for <task> and send it to the configured provider.
|
|
116
|
+
|
|
117
|
+
With --prompt-only (or when no provider/key/Ollama is reachable), outputs
|
|
118
|
+
the assembled prompt bundle to stdout for the calling agent to process.
|
|
119
|
+
"""
|
|
108
120
|
from codeforerunner import providers as _providers
|
|
109
121
|
from codeforerunner.config import load_from_repo
|
|
110
122
|
|
|
111
123
|
repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
112
124
|
cfg = load_from_repo(repo_root)
|
|
113
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
|
+
|
|
114
136
|
explicit_provider = args.provider or (cfg.provider if cfg else None)
|
|
115
137
|
provider_name = explicit_provider or "anthropic"
|
|
116
138
|
model = args.model or (cfg.model if cfg else None)
|
|
@@ -118,18 +140,6 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
118
140
|
provider = provider_cls()
|
|
119
141
|
model = model or provider.default_model
|
|
120
142
|
|
|
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
143
|
env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
|
|
134
144
|
api_key = os.environ.get(env_var)
|
|
135
145
|
if api_key is None and provider_name != "ollama":
|
|
@@ -140,16 +150,26 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
140
150
|
if not args.model:
|
|
141
151
|
model = provider.default_model
|
|
142
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
|
|
143
166
|
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)
|
|
167
|
+
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
|
|
148
168
|
return 3
|
|
149
169
|
|
|
150
170
|
if getattr(args, "stream", False):
|
|
151
171
|
try:
|
|
152
|
-
for chunk in provider.stream(prompt=
|
|
172
|
+
for chunk in provider.stream(prompt=bundle, model=model, api_key=api_key):
|
|
153
173
|
sys.stdout.write(chunk)
|
|
154
174
|
sys.stdout.flush()
|
|
155
175
|
except _providers.ProviderError as e:
|
|
@@ -159,7 +179,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
159
179
|
return 0
|
|
160
180
|
|
|
161
181
|
try:
|
|
162
|
-
result = provider.complete(prompt=
|
|
182
|
+
result = provider.complete(prompt=bundle, model=model, api_key=api_key)
|
|
163
183
|
except _providers.ProviderError as e:
|
|
164
184
|
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
165
185
|
return 4
|
|
@@ -183,7 +203,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
183
203
|
print(f"wrote {cfg_path}", file=sys.stderr)
|
|
184
204
|
else:
|
|
185
205
|
print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
|
|
186
|
-
findings = doctor.run(root)
|
|
206
|
+
findings = doctor.run(root, run_scripts=getattr(args, "run_scripts", False))
|
|
187
207
|
sys.stdout.write(doctor.format_report(findings) + "\n")
|
|
188
208
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
189
209
|
|
|
@@ -236,6 +256,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
236
256
|
action="store_true",
|
|
237
257
|
help="write a starter forerunner.config.yaml if absent",
|
|
238
258
|
)
|
|
259
|
+
s_doctor.add_argument(
|
|
260
|
+
"--run-scripts",
|
|
261
|
+
dest="run_scripts",
|
|
262
|
+
action="store_true",
|
|
263
|
+
default=False,
|
|
264
|
+
help="allow executing Python scripts from the target repo to validate skill copies (off by default)",
|
|
265
|
+
)
|
|
239
266
|
s_doctor.set_defaults(func=cmd_doctor)
|
|
240
267
|
|
|
241
268
|
s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
|
|
@@ -243,6 +270,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
243
270
|
s_gen.add_argument("--provider", help="override config provider")
|
|
244
271
|
s_gen.add_argument("--model", help="override config model")
|
|
245
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
|
+
)
|
|
246
279
|
s_gen.set_defaults(func=cmd_generate)
|
|
247
280
|
|
|
248
281
|
from codeforerunner import installer
|
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
|
|
@@ -37,9 +36,6 @@ class VersionAuditConfig:
|
|
|
37
36
|
class ForerunnerConfig:
|
|
38
37
|
provider: str = "anthropic"
|
|
39
38
|
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
39
|
approaching_eol_threshold_months: int = 6
|
|
44
40
|
ignore_patterns: tuple[str, ...] = ()
|
|
45
41
|
api_key_env: dict[str, str] = field(default_factory=dict)
|
|
@@ -117,13 +113,20 @@ def _parse_check(raw: Any) -> CheckConfig:
|
|
|
117
113
|
)
|
|
118
114
|
|
|
119
115
|
|
|
116
|
+
def _to_int(value: Any, field_name: str) -> int:
|
|
117
|
+
try:
|
|
118
|
+
return int(value)
|
|
119
|
+
except (TypeError, ValueError) as e:
|
|
120
|
+
raise ConfigError(f"{field_name}: expected integer, got {value!r}") from e
|
|
121
|
+
|
|
122
|
+
|
|
120
123
|
def _parse_version_audit(raw: Any) -> VersionAuditConfig:
|
|
121
124
|
if raw is None:
|
|
122
125
|
return VersionAuditConfig()
|
|
123
126
|
_require_type(raw, dict, "tasks.version_audit")
|
|
124
127
|
return VersionAuditConfig(
|
|
125
128
|
enabled=bool(raw.get("enabled", True)),
|
|
126
|
-
stale_after_days=
|
|
129
|
+
stale_after_days=_to_int(raw.get("stale_after_days", 30), "tasks.version_audit.stale_after_days"),
|
|
127
130
|
fetch_live_eol_data=bool(raw.get("fetch_live_eol_data", False)),
|
|
128
131
|
)
|
|
129
132
|
|
|
@@ -147,10 +150,9 @@ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
|
|
|
147
150
|
return ForerunnerConfig(
|
|
148
151
|
provider=provider,
|
|
149
152
|
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)),
|
|
153
|
+
approaching_eol_threshold_months=_to_int(
|
|
154
|
+
raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
|
|
155
|
+
),
|
|
154
156
|
ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
|
|
155
157
|
api_key_env=_parse_api_key_env(raw.get("api_key_env")),
|
|
156
158
|
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
|
|
@@ -50,17 +51,27 @@ def _installed_marketplace_destination() -> Path:
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def _load_script_module(repo: Path, relpath: str, module_name: str):
|
|
54
|
+
# L3: unique name prevents stale cached module on repeated calls
|
|
55
|
+
unique_name = f"{module_name}_{uuid.uuid4().hex}"
|
|
53
56
|
script_path = repo / relpath
|
|
54
|
-
spec = importlib.util.spec_from_file_location(
|
|
57
|
+
spec = importlib.util.spec_from_file_location(unique_name, script_path)
|
|
55
58
|
if spec is None or spec.loader is None: # pragma: no cover - defensive
|
|
56
59
|
raise RuntimeError(f"cannot load {script_path}")
|
|
57
60
|
module = importlib.util.module_from_spec(spec)
|
|
58
|
-
sys.modules[
|
|
61
|
+
sys.modules[unique_name] = module
|
|
59
62
|
spec.loader.exec_module(module)
|
|
60
63
|
return module
|
|
61
64
|
|
|
62
65
|
|
|
63
|
-
def _check_skill_body_parity(repo: Path) -> list[Finding]:
|
|
66
|
+
def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
67
|
+
if not run_scripts:
|
|
68
|
+
return [
|
|
69
|
+
Finding(
|
|
70
|
+
"warn",
|
|
71
|
+
"skill-body-parity",
|
|
72
|
+
"skipping script validation (pass --run-scripts to allow executing repo scripts)",
|
|
73
|
+
)
|
|
74
|
+
]
|
|
64
75
|
try:
|
|
65
76
|
skill_mod = _load_script_module(
|
|
66
77
|
repo, "scripts/validate_skill_copies.py", "_forerunner_doctor_skill_copies"
|
|
@@ -104,7 +115,15 @@ def _check_skill_body_parity(repo: Path) -> list[Finding]:
|
|
|
104
115
|
return findings
|
|
105
116
|
|
|
106
117
|
|
|
107
|
-
def _check_codex_marketplace(repo: Path) -> list[Finding]:
|
|
118
|
+
def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
119
|
+
if not run_scripts:
|
|
120
|
+
return [
|
|
121
|
+
Finding(
|
|
122
|
+
"warn",
|
|
123
|
+
"codex-marketplace",
|
|
124
|
+
"skipping script validation (pass --run-scripts to allow executing repo scripts)",
|
|
125
|
+
)
|
|
126
|
+
]
|
|
108
127
|
try:
|
|
109
128
|
mp_mod = _load_script_module(
|
|
110
129
|
repo,
|
|
@@ -224,6 +243,19 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
|
224
243
|
return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
|
|
225
244
|
|
|
226
245
|
|
|
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
|
+
|
|
227
259
|
def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
228
260
|
from codeforerunner.providers.ollama import is_available as _ollama_available
|
|
229
261
|
|
|
@@ -237,11 +269,19 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
237
269
|
"no config; Ollama running — generate will use local mode automatically",
|
|
238
270
|
)
|
|
239
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
|
+
]
|
|
240
280
|
return [
|
|
241
281
|
Finding(
|
|
242
282
|
"ok",
|
|
243
283
|
"provider-api-key",
|
|
244
|
-
f"no {CONFIG_FILENAME}; set an API key in config
|
|
284
|
+
f"no {CONFIG_FILENAME}; set an API key in config, start Ollama, or use skill mode (`forerunner generate --prompt-only`)",
|
|
245
285
|
)
|
|
246
286
|
]
|
|
247
287
|
try:
|
|
@@ -279,7 +319,12 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
279
319
|
Finding(
|
|
280
320
|
"warn",
|
|
281
321
|
"provider-api-key",
|
|
282
|
-
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run"
|
|
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
|
+
),
|
|
283
328
|
)
|
|
284
329
|
]
|
|
285
330
|
|
|
@@ -288,15 +333,17 @@ _STARTER_CONFIG = """\
|
|
|
288
333
|
# forerunner.config.yaml — generated by `forerunner doctor --fix`
|
|
289
334
|
# See https://github.com/derek-palmer/codeforerunner for docs.
|
|
290
335
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
336
|
+
tasks:
|
|
337
|
+
check:
|
|
338
|
+
enabled_rules:
|
|
339
|
+
- R1-no-cli
|
|
340
|
+
- R2-no-pre-commit
|
|
341
|
+
- R3-no-ci
|
|
342
|
+
- R4-no-installer
|
|
343
|
+
- R5-no-python-package
|
|
344
|
+
- R7-no-mcp
|
|
345
|
+
- R8-no-marketplace
|
|
346
|
+
ignore_paths: []
|
|
300
347
|
"""
|
|
301
348
|
|
|
302
349
|
|
|
@@ -304,11 +351,11 @@ def starter_config() -> str:
|
|
|
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]:
|
|
308
355
|
repo = repo.resolve()
|
|
309
356
|
findings: list[Finding] = []
|
|
310
|
-
findings.extend(_check_skill_body_parity(repo))
|
|
311
|
-
findings.extend(_check_codex_marketplace(repo))
|
|
357
|
+
findings.extend(_check_skill_body_parity(repo, run_scripts=run_scripts))
|
|
358
|
+
findings.extend(_check_codex_marketplace(repo, run_scripts=run_scripts))
|
|
312
359
|
findings.extend(_check_installed_destinations(repo))
|
|
313
360
|
findings.extend(_check_config_loadable(repo))
|
|
314
361
|
findings.extend(_check_provider_api_key(repo))
|
|
@@ -317,14 +364,10 @@ def run(repo: Path) -> list[Finding]:
|
|
|
317
364
|
|
|
318
365
|
def format_report(findings: list[Finding]) -> str:
|
|
319
366
|
lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
|
|
320
|
-
counts = {"ok": 0, "warn": 0, "error": 0}
|
|
367
|
+
counts: dict[str, int] = {"ok": 0, "warn": 0, "error": 0}
|
|
321
368
|
for f in findings:
|
|
322
369
|
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
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
|
-
)
|
|
370
|
+
summary = f"summary: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error"
|
|
328
371
|
lines.append(summary)
|
|
329
372
|
return "\n".join(lines)
|
|
330
373
|
|
|
@@ -340,9 +383,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
340
383
|
default=Path.cwd(),
|
|
341
384
|
help="repo root (default: cwd)",
|
|
342
385
|
)
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--run-scripts",
|
|
388
|
+
action="store_true",
|
|
389
|
+
default=False,
|
|
390
|
+
help="allow executing Python scripts from the target repo (off by default for safety)",
|
|
391
|
+
)
|
|
343
392
|
args = parser.parse_args(argv)
|
|
344
393
|
|
|
345
|
-
findings = run(args.repo)
|
|
394
|
+
findings = run(args.repo, run_scripts=args.run_scripts)
|
|
346
395
|
print(format_report(findings))
|
|
347
396
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
348
397
|
|
codeforerunner/installer.py
CHANGED
|
@@ -95,6 +95,7 @@ def install_all_skills(
|
|
|
95
95
|
src_path = repo_root / "plugins" / "codeforerunner" / "skills" / slug / "SKILL.md"
|
|
96
96
|
if not src_path.is_file():
|
|
97
97
|
print(f"warning: skill source not found: {src_path}", file=err)
|
|
98
|
+
any_error = True
|
|
98
99
|
continue
|
|
99
100
|
try:
|
|
100
101
|
target = resolve_skill_target(agent, slug)
|
|
@@ -116,7 +117,11 @@ def install_all_skills(
|
|
|
116
117
|
print(f"{prefix}{action}: {dest}", file=out)
|
|
117
118
|
if not check_only:
|
|
118
119
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
120
|
+
try:
|
|
121
|
+
dest.write_bytes(src_path.read_bytes())
|
|
122
|
+
except OSError as e:
|
|
123
|
+
print(f"error: failed to write {dest}: {e}", file=err)
|
|
124
|
+
any_error = True
|
|
120
125
|
return EXIT_OK if not any_error else EXIT_BODY_MISMATCH
|
|
121
126
|
|
|
122
127
|
|
|
@@ -188,7 +193,8 @@ def find_markers(text: str) -> tuple[int, int] | None:
|
|
|
188
193
|
def overlay(dest_text: str, source_body: str) -> str:
|
|
189
194
|
"""Replace managed region in-place. Caller has verified markers exist."""
|
|
190
195
|
span = find_markers(dest_text)
|
|
191
|
-
|
|
196
|
+
if span is None:
|
|
197
|
+
raise RuntimeError("overlay: span is None — this is a bug")
|
|
192
198
|
a, b = span
|
|
193
199
|
managed = f"{MARKER_BEGIN}\n{source_body}\n{MARKER_END}"
|
|
194
200
|
return dest_text[:a] + managed + dest_text[b:]
|
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,7 @@ 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
|
-
state: dict[str, Any] = {"scan_called": False}
|
|
117
|
+
state: dict[str, Any] = {"scan_called": False, "initialized": False}
|
|
110
118
|
for raw in stdin:
|
|
111
119
|
line = raw.strip()
|
|
112
120
|
if not line:
|
|
@@ -45,7 +45,7 @@ class AnthropicProvider:
|
|
|
45
45
|
},
|
|
46
46
|
)
|
|
47
47
|
try:
|
|
48
|
-
with urllib.request.urlopen(req) as resp:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
49
49
|
raw = resp.read()
|
|
50
50
|
except urllib.error.HTTPError as e:
|
|
51
51
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -90,7 +90,7 @@ class AnthropicProvider:
|
|
|
90
90
|
},
|
|
91
91
|
)
|
|
92
92
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
93
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
94
|
except urllib.error.HTTPError as e:
|
|
95
95
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
96
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -16,6 +16,8 @@ class GoogleProvider:
|
|
|
16
16
|
default_env_var = "GOOGLE_API_KEY"
|
|
17
17
|
default_model = "gemini-2.5-pro"
|
|
18
18
|
|
|
19
|
+
# Google REST API requires the key in the URL query string — this is the documented
|
|
20
|
+
# mechanism and cannot be changed. Be aware the key may appear in proxy/server logs.
|
|
19
21
|
endpoint_template = (
|
|
20
22
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
21
23
|
)
|
|
@@ -48,7 +50,7 @@ class GoogleProvider:
|
|
|
48
50
|
headers={"content-type": "application/json"},
|
|
49
51
|
)
|
|
50
52
|
try:
|
|
51
|
-
with urllib.request.urlopen(req) as resp:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
52
54
|
raw = resp.read()
|
|
53
55
|
except urllib.error.HTTPError as e:
|
|
54
56
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -90,7 +92,7 @@ class GoogleProvider:
|
|
|
90
92
|
headers={"content-type": "application/json"},
|
|
91
93
|
)
|
|
92
94
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
95
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
96
|
except urllib.error.HTTPError as e:
|
|
95
97
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
98
|
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:
|
|
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:
|
|
@@ -38,6 +60,7 @@ class OllamaProvider:
|
|
|
38
60
|
# api_key is interpreted as a base URL override; fall back to env then default.
|
|
39
61
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
40
62
|
base = base.rstrip("/")
|
|
63
|
+
_validate_ollama_base(base)
|
|
41
64
|
model = model or self.default_model
|
|
42
65
|
url = f"{base}/api/generate"
|
|
43
66
|
body = json.dumps(
|
|
@@ -50,7 +73,7 @@ class OllamaProvider:
|
|
|
50
73
|
headers={"content-type": "application/json"},
|
|
51
74
|
)
|
|
52
75
|
try:
|
|
53
|
-
with urllib.request.urlopen(req) as resp:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
54
77
|
raw = resp.read()
|
|
55
78
|
except urllib.error.HTTPError as e:
|
|
56
79
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -75,6 +98,7 @@ class OllamaProvider:
|
|
|
75
98
|
) -> Iterator[str]:
|
|
76
99
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
77
100
|
base = base.rstrip("/")
|
|
101
|
+
_validate_ollama_base(base)
|
|
78
102
|
model = model or self.default_model
|
|
79
103
|
url = f"{base}/api/generate"
|
|
80
104
|
body = json.dumps(
|
|
@@ -87,7 +111,7 @@ class OllamaProvider:
|
|
|
87
111
|
headers={"content-type": "application/json"},
|
|
88
112
|
)
|
|
89
113
|
try:
|
|
90
|
-
resp = urllib.request.urlopen(req)
|
|
114
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
91
115
|
except urllib.error.HTTPError as e:
|
|
92
116
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
93
117
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -43,7 +43,7 @@ class OpenAIProvider:
|
|
|
43
43
|
},
|
|
44
44
|
)
|
|
45
45
|
try:
|
|
46
|
-
with urllib.request.urlopen(req) as resp:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
47
47
|
raw = resp.read()
|
|
48
48
|
except urllib.error.HTTPError as e:
|
|
49
49
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -86,7 +86,7 @@ class OpenAIProvider:
|
|
|
86
86
|
},
|
|
87
87
|
)
|
|
88
88
|
try:
|
|
89
|
-
resp = urllib.request.urlopen(req)
|
|
89
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
90
90
|
except urllib.error.HTTPError as e:
|
|
91
91
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
92
92
|
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.2
|
|
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
1
|
codeforerunner/__init__.py,sha256=4ItVS_FzLddTK77jExpkV3QJ1nHl2Bh-QIujM7Hg_5w,205
|
|
2
|
-
codeforerunner/bundle.py,sha256=
|
|
2
|
+
codeforerunner/bundle.py,sha256=2GByXu-oFbplWpCJuGnF_qobcHqv8YjyTZX1BU4WoJM,2053
|
|
3
3
|
codeforerunner/check.py,sha256=5sJVwMMSvVCrRmwBIunYbn2pINroUjONrVJAs5ov3O4,8188
|
|
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=
|
|
4
|
+
codeforerunner/cli.py,sha256=Jxj-x_GwZO74l86ImrFwF0korKTxL2aKWLpcyGUId1U,11058
|
|
5
|
+
codeforerunner/config.py,sha256=wnqJmMq1cnCJpOvFlGSpztASED1G0BYyyhOAumkTxkY,6117
|
|
6
|
+
codeforerunner/doctor.py,sha256=f6atn2_fbZYAygCwCuU6lg5fsZprUtavWX5n9edmARc,12771
|
|
7
|
+
codeforerunner/installer.py,sha256=d5Ymei-nnVYXtGIypV6Ocse8QGeXMY6w-Cw-qo2a5NA,13645
|
|
8
|
+
codeforerunner/mcp_server.py,sha256=HuoGqYLBwJQgngqT_2rtdqh7LztX63rBAX_7YZjBpzI,5072
|
|
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
|
|
@@ -23,14 +23,14 @@ codeforerunner/prompts/tasks/scan.md,sha256=hYXf-IL1kgpBPHJapRrwTu88cLZ7y3bCmAq9
|
|
|
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
25
|
codeforerunner/providers/__init__.py,sha256=hoLODdqQ-beA7-MVFR6aoE29ZUSzxGVPLhwNXNN1xw4,1020
|
|
26
|
-
codeforerunner/providers/anthropic.py,sha256=
|
|
26
|
+
codeforerunner/providers/anthropic.py,sha256=edCZaNwB2WX6mdcQQN9khoKedZDiK13c73Ld8D1Puq4,4075
|
|
27
27
|
codeforerunner/providers/base.py,sha256=MMrOUVOXHWP1td-TndxhLhDyDPJZGExZCeFopZUSRCo,923
|
|
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.
|
|
28
|
+
codeforerunner/providers/google.py,sha256=JsMgEjFIyKo_LGYosKzWLntGB_vw7ArDwJf7XSYonoA,4184
|
|
29
|
+
codeforerunner/providers/ollama.py,sha256=yI8RfjZmTw8Iaj2FDvp85uLQ_4oigHbyYN8Pag9FO5g,4759
|
|
30
|
+
codeforerunner/providers/openai.py,sha256=BT_YzVQTDxKs-oV_6r4614knjE4LBFXdTZzvs5_yr4Y,3851
|
|
31
|
+
codeforerunner-0.4.2.dist-info/licenses/LICENSE.md,sha256=iIhmJHib6GbdjcwiDMM-npiNRf3XgASom1WsOJivEdc,2915
|
|
32
|
+
codeforerunner-0.4.2.dist-info/METADATA,sha256=sVF0AMF3VulKTbPgHXcIhzd8YfntPV6bdYpF3wHh2bo,9873
|
|
33
|
+
codeforerunner-0.4.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
codeforerunner-0.4.2.dist-info/entry_points.txt,sha256=3p8BbPlq-wfcXk42tsweKePRaGlZ1WXho1gOkuZGyIQ,55
|
|
35
|
+
codeforerunner-0.4.2.dist-info/top_level.txt,sha256=pV1rt0-NIpNEotKXpL_sF2060DHr-_0F86LWhUlvXis,15
|
|
36
|
+
codeforerunner-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|