codeforerunner 0.4.1__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 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
- for candidate in [here, *here.parents]:
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 cmd_doc(args: argparse.Namespace) -> int:
18
- """Resolve base + partials + task bundle to stdout."""
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
- sys.stdout.write(resolve_bundle(prompts_root, args.task))
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
- msg = f"error: missing API key; set ${env_var}"
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=buf.getvalue(), model=model, api_key=api_key):
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=buf.getvalue(), model=model, api_key=api_key)
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=int(raw.get("stale_after_days", 30)),
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
- output_dir=Path(_require_type(raw.get("output_dir", "docs"), str, "output_dir")),
151
- context_max_files=int(raw.get("context_max_files", 30)),
152
- context_max_lines_per_file=int(raw.get("context_max_lines_per_file", 300)),
153
- approaching_eol_threshold_months=int(raw.get("approaching_eol_threshold_months", 6)),
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(module_name, script_path)
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[module_name] = module
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 or start Ollama for keyless local generation",
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
- enabled_rules:
292
- - R1-no-cli
293
- - R2-no-pre-commit
294
- - R3-no-ci
295
- - R4-no-installer
296
- - R5-no-python-package
297
- - R7-no-mcp
298
- - R8-no-marketplace
299
- ignore_paths: []
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
 
@@ -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
- dest.write_bytes(src_path.read_bytes())
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
- assert span is not None
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:]
@@ -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
- for raw in task_path.read_text(encoding="utf-8").splitlines():
31
- line = raw.strip()
32
- if not line:
33
- continue
34
- return line.lstrip("#").strip()
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
- if not isinstance(name, str) or not task_path.is_file():
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.1
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.3.2
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=wWlhNaja8HPLzN-9pxiSrFD8X0mGi-c1HM9HRyVhmzk,2020
2
+ codeforerunner/bundle.py,sha256=2GByXu-oFbplWpCJuGnF_qobcHqv8YjyTZX1BU4WoJM,2053
3
3
  codeforerunner/check.py,sha256=5sJVwMMSvVCrRmwBIunYbn2pINroUjONrVJAs5ov3O4,8188
4
- codeforerunner/cli.py,sha256=fBojCOPEEzQdXxYB0Intjhax90l7Rz7sfsgeFpozP58,9633
5
- codeforerunner/config.py,sha256=REs4FgmSSn7R2tLIwLJPL8VmSCGkTvw8J2SqBOggzho,6206
6
- codeforerunner/doctor.py,sha256=4VE7nCGdJKpzWtqK3ut-SbrOENdfLNLuvA_wxn-5fcM,10859
7
- codeforerunner/installer.py,sha256=60CMYbV8di-0iLE8jbsLGScdgU5VSJTOO_PhoBtqYYY,13395
8
- codeforerunner/mcp_server.py,sha256=oIfuAR7e_rH--B1aLOATVflyWAyGpkyeeXI4SAI4eTg,4657
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=kECeFMCeSJHcsUPvF93ECv52wXmLR6H21rFNwPzRhaM,4049
26
+ codeforerunner/providers/anthropic.py,sha256=edCZaNwB2WX6mdcQQN9khoKedZDiK13c73Ld8D1Puq4,4075
27
27
  codeforerunner/providers/base.py,sha256=MMrOUVOXHWP1td-TndxhLhDyDPJZGExZCeFopZUSRCo,923
28
- codeforerunner/providers/google.py,sha256=OWEE0FNupFWmZCeilIrgYUYDHH1iWvIwHEEsHYQoFFY,3979
29
- codeforerunner/providers/ollama.py,sha256=Q8ACojaeiBgPQgDFxP7KKM5r4Ccu6dDspNFza1vbOzw,3871
30
- codeforerunner/providers/openai.py,sha256=999ZzIVh0cqW4xDnzK_NACqfJxNziHwpVjwmw9_jjRw,3825
31
- codeforerunner-0.4.1.dist-info/licenses/LICENSE.md,sha256=iIhmJHib6GbdjcwiDMM-npiNRf3XgASom1WsOJivEdc,2915
32
- codeforerunner-0.4.1.dist-info/METADATA,sha256=c7yCFeXV3PfXwbJpb1hGe5WmPvA45t4W6A75mzC-F28,9873
33
- codeforerunner-0.4.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
34
- codeforerunner-0.4.1.dist-info/entry_points.txt,sha256=3p8BbPlq-wfcXk42tsweKePRaGlZ1WXho1gOkuZGyIQ,55
35
- codeforerunner-0.4.1.dist-info/top_level.txt,sha256=pV1rt0-NIpNEotKXpL_sF2060DHr-_0F86LWhUlvXis,15
36
- codeforerunner-0.4.1.dist-info/RECORD,,
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,,