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.
@@ -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
- 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/check.py CHANGED
@@ -11,6 +11,8 @@ from codeforerunner.config import CheckConfig
11
11
 
12
12
  @dataclass(frozen=True)
13
13
  class Violation:
14
+ """Single rule match: which doc, which line, which rule, and what it means."""
15
+
14
16
  path: Path
15
17
  line: int
16
18
  rule_id: str
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
 
@@ -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
- """Default + --agents-only = onboarding bundle only. --full prepends scan."""
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
- 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)
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=buf.getvalue(), model=model, api_key=api_key):
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=buf.getvalue(), model=model, api_key=api_key)
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=int(raw.get("stale_after_days", 30)),
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
- 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)),
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(module_name, script_path)
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[module_name] = module
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 or start Ollama for keyless local generation",
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
- 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: []
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] = 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
- )
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
 
@@ -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
- dest.write_bytes(src_path.read_bytes())
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
- assert span is not None
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)")
@@ -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,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
- state: dict[str, Any] = {"scan_called": False}
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
@@ -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.1
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.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
- codeforerunner/__init__.py,sha256=4ItVS_FzLddTK77jExpkV3QJ1nHl2Bh-QIujM7Hg_5w,205
2
- codeforerunner/bundle.py,sha256=wWlhNaja8HPLzN-9pxiSrFD8X0mGi-c1HM9HRyVhmzk,2020
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
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=hoLODdqQ-beA7-MVFR6aoE29ZUSzxGVPLhwNXNN1xw4,1020
26
- codeforerunner/providers/anthropic.py,sha256=kECeFMCeSJHcsUPvF93ECv52wXmLR6H21rFNwPzRhaM,4049
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,,
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,,