codeforerunner 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (53) hide show
  1. {codeforerunner-0.4.0/src/codeforerunner.egg-info → codeforerunner-0.4.2}/PKG-INFO +2 -2
  2. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/README.md +1 -1
  3. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/pyproject.toml +1 -1
  4. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/bundle.py +2 -1
  5. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/cli.py +60 -27
  6. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/config.py +11 -9
  7. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/doctor.py +74 -25
  8. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/installer.py +8 -2
  9. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/mcp_server.py +15 -7
  10. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/anthropic.py +2 -2
  11. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/google.py +4 -2
  12. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/ollama.py +26 -2
  13. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/openai.py +2 -2
  14. {codeforerunner-0.4.0 → codeforerunner-0.4.2/src/codeforerunner.egg-info}/PKG-INFO +2 -2
  15. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_cli.py +94 -5
  16. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_config.py +0 -4
  17. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_doctor.py +11 -7
  18. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_mcp_server.py +2 -1
  19. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/LICENSE.md +0 -0
  20. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/setup.cfg +0 -0
  21. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/__init__.py +0 -0
  22. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/check.py +0 -0
  23. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/context-format.md +0 -0
  24. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
  25. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
  26. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/system/base.md +0 -0
  27. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
  28. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/audit.md +0 -0
  29. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
  30. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/check.md +0 -0
  31. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
  32. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/flows.md +0 -0
  33. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
  34. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/readme.md +0 -0
  35. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/review.md +0 -0
  36. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/scan.md +0 -0
  37. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
  38. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
  39. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/__init__.py +0 -0
  40. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner/providers/base.py +0 -0
  41. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/SOURCES.txt +0 -0
  42. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
  43. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/entry_points.txt +0 -0
  44. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/requires.txt +0 -0
  45. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/top_level.txt +0 -0
  46. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_check.py +0 -0
  47. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_check_config_integration.py +0 -0
  48. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_examples.py +0 -0
  49. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_hooks_manifest.py +0 -0
  50. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_installer.py +0 -0
  51. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_providers.py +0 -0
  52. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_validate_codex_marketplace.py +0 -0
  53. {codeforerunner-0.4.0 → codeforerunner-0.4.2}/tests/test_workflows_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforerunner
3
- Version: 0.4.0
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.
@@ -148,7 +148,7 @@ forerunner check # run any time or as pre-commit hook
148
148
  ## GitHub Action
149
149
 
150
150
  ```yaml
151
- - uses: derek-palmer/codeforerunner@v0.3.2
151
+ - uses: derek-palmer/codeforerunner@v0.4.2
152
152
  ```
153
153
 
154
154
  No-op when `forerunner.config.yaml` is absent.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codeforerunner"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Model-agnostic repository documentation tooling (prompt-first; thin CLI)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
 
@@ -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
@@ -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")),
@@ -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.0
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.
@@ -392,10 +392,11 @@ def test_generate_no_fallback_when_config_provider_set_and_key_missing(
392
392
  assert "missing API key" in cap.err
393
393
 
394
394
 
395
- def test_generate_missing_key_includes_ollama_hint_when_ollama_absent(
395
+ def test_generate_no_key_no_ollama_no_explicit_provider_emits_bundle(
396
396
  tmp_path, capsys, monkeypatch
397
397
  ):
398
- """No explicit provider, no API key, Ollama not running → error + Ollama hint."""
398
+ """No explicit provider, no API key, Ollama not running → skill-mode auto-detect:
399
+ emit bundle to stdout and return 0 (the calling agent is the model)."""
399
400
  _seed_repo_with_config(tmp_path)
400
401
  (tmp_path / "forerunner.config.yaml").unlink()
401
402
 
@@ -407,9 +408,8 @@ def test_generate_missing_key_includes_ollama_hint_when_ollama_absent(
407
408
  rc = main(["--repo", str(tmp_path), "generate", "readme"])
408
409
 
409
410
  cap = capsys.readouterr()
410
- assert rc == 3
411
- assert "missing API key" in cap.err
412
- assert "Ollama" in cap.err
411
+ assert rc == 0
412
+ assert "system: base.md" in cap.out
413
413
 
414
414
 
415
415
  def test_generate_ollama_fallback_uses_explicit_model(tmp_path, capsys, monkeypatch):
@@ -437,3 +437,92 @@ def test_generate_ollama_fallback_uses_explicit_model(tmp_path, capsys, monkeypa
437
437
 
438
438
  assert rc == 0
439
439
  assert calls[0]["model"] == "llama3.2"
440
+
441
+
442
+ # ── --prompt-only and skill-mode auto-detect ─────────────────────────────────
443
+
444
+ def test_generate_prompt_only_outputs_bundle_without_api_call(tmp_path, capsys, monkeypatch):
445
+ """--prompt-only emits the bundle and returns 0; no model is invoked."""
446
+ _seed_repo_with_config(tmp_path)
447
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
448
+ monkeypatch.setenv("FORERUNNER_SCAN_DONE", "1") # silence scan-first warning
449
+
450
+ rc = main(["--repo", str(tmp_path), "generate", "--prompt-only", "readme"])
451
+
452
+ cap = capsys.readouterr()
453
+ assert rc == 0
454
+ assert "system: base.md" in cap.out
455
+ assert "missing API key" not in cap.err # no provider error messages
456
+
457
+
458
+ def test_generate_prompt_only_scan_task(tmp_path, capsys, monkeypatch):
459
+ """--prompt-only works for the scan task (scan is exempt from scan-first warning)."""
460
+ _seed_repo_with_config(tmp_path)
461
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
462
+
463
+ rc = main(["--repo", str(tmp_path), "generate", "--prompt-only", "scan"])
464
+
465
+ cap = capsys.readouterr()
466
+ assert rc == 0
467
+ assert "scan task" in cap.out # stub scan.md content present
468
+
469
+
470
+ def test_generate_skill_mode_autodetect_no_tty(tmp_path, capsys, monkeypatch):
471
+ """No key + no Ollama + no explicit provider + non-TTY stdout → bundle emitted cleanly."""
472
+ _seed_repo_with_config(tmp_path)
473
+ (tmp_path / "forerunner.config.yaml").unlink()
474
+
475
+ from unittest.mock import patch
476
+
477
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
478
+
479
+ with patch("codeforerunner.providers.ollama_available", return_value=False):
480
+ rc = main(["--repo", str(tmp_path), "generate", "readme"])
481
+
482
+ cap = capsys.readouterr()
483
+ assert rc == 0
484
+ assert "system: base.md" in cap.out
485
+ # Non-TTY: no "info:" message on stderr
486
+ assert "info:" not in cap.err
487
+
488
+
489
+ def test_generate_explicit_provider_no_key_still_errors(tmp_path, capsys, monkeypatch):
490
+ """Explicit --provider with no key → error (not silent bundle output)."""
491
+ _seed_repo_with_config(tmp_path)
492
+ (tmp_path / "forerunner.config.yaml").unlink()
493
+
494
+ from unittest.mock import patch
495
+
496
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
497
+
498
+ with patch("codeforerunner.providers.ollama_available", return_value=False):
499
+ rc = main(["--repo", str(tmp_path), "generate", "--provider", "anthropic", "readme"])
500
+
501
+ cap = capsys.readouterr()
502
+ assert rc == 3
503
+ assert "missing API key" in cap.err
504
+
505
+
506
+ def test_generate_stream_flag_yields_chunks(tmp_path, capsys, monkeypatch):
507
+ """--stream calls provider.stream() and writes chunks to stdout."""
508
+ _seed_repo_with_config(tmp_path)
509
+ (tmp_path / "forerunner.config.yaml").unlink()
510
+ chunks = ["hello", " ", "world"]
511
+
512
+ class FakeProvider:
513
+ default_env_var = "FAKE_API_KEY"
514
+ default_model = "fake-stream"
515
+
516
+ def stream(self, *, prompt, model=None, api_key=None):
517
+ yield from chunks
518
+
519
+ from codeforerunner import providers
520
+
521
+ monkeypatch.setitem(providers.REGISTRY, "fake", FakeProvider)
522
+ monkeypatch.setenv("FAKE_API_KEY", "secret")
523
+
524
+ rc = main(["--repo", str(tmp_path), "generate", "--provider", "fake", "--stream", "readme"])
525
+ cap = capsys.readouterr()
526
+
527
+ assert rc == 0
528
+ assert cap.out == "hello world\n"
@@ -33,8 +33,6 @@ def test_load_full_example_shape(tmp_path):
33
33
  """
34
34
  provider: openai
35
35
  model: gpt-x
36
- output_dir: documents
37
- context_max_files: 50
38
36
  ignore_patterns:
39
37
  - "*.test.ts"
40
38
  tasks:
@@ -52,8 +50,6 @@ tasks:
52
50
  assert cfg is not None
53
51
  assert cfg.provider == "openai"
54
52
  assert cfg.model == "gpt-x"
55
- assert cfg.output_dir == Path("documents")
56
- assert cfg.context_max_files == 50
57
53
  assert cfg.ignore_patterns == ("*.test.ts",)
58
54
  assert cfg.check.block_on == ("HIGH",)
59
55
  assert cfg.check.warn_on == ("MEDIUM", "LOW")
@@ -46,7 +46,7 @@ def test_skill_body_drift_reported(tmp_path: Path):
46
46
  text = drifted.read_text(encoding="utf-8")
47
47
  drifted.write_text(text + "\n\nINJECTED DRIFT LINE\n", encoding="utf-8")
48
48
 
49
- findings = run(repo)
49
+ findings = run(repo, run_scripts=True)
50
50
  parity_errors = [
51
51
  f for f in findings if f.check == "skill-body-parity" and f.severity == "error"
52
52
  ]
@@ -58,7 +58,7 @@ def test_marketplace_invalid_reported(tmp_path: Path):
58
58
  bad = repo / "plugins/codex/marketplace.json"
59
59
  bad.write_text(json.dumps({"marketplace": {"id": "x", "name": "x", "version": "1.0.0"}}), encoding="utf-8")
60
60
 
61
- findings = run(repo)
61
+ findings = run(repo, run_scripts=True)
62
62
  mp_errors = [
63
63
  f for f in findings if f.check == "codex-marketplace" and f.severity == "error"
64
64
  ]
@@ -81,10 +81,13 @@ def test_main_exits_zero_when_no_errors(capsys):
81
81
 
82
82
 
83
83
  def test_provider_api_key_finding_present_with_config(tmp_path: Path, monkeypatch):
84
+ from unittest.mock import patch
84
85
  repo = _copy_repo_layout(tmp_path)
85
86
  (repo / "forerunner.config.yaml").write_text("", encoding="utf-8")
86
87
  monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
87
- findings = run(repo)
88
+ # Force skill mode off so warn path is exercised
89
+ with patch("codeforerunner.doctor._skill_mode_active", return_value=False):
90
+ findings = run(repo)
88
91
  matches = [f for f in findings if f.check == "provider-api-key"]
89
92
  assert len(matches) == 1
90
93
  assert matches[0].severity == "warn"
@@ -132,7 +135,7 @@ def test_main_exits_one_when_error_present(tmp_path: Path, capsys):
132
135
  drifted.write_text(
133
136
  drifted.read_text(encoding="utf-8") + "\nDRIFT\n", encoding="utf-8"
134
137
  )
135
- rc = main(["--repo", str(repo)])
138
+ rc = main(["--repo", str(repo), "--run-scripts"])
136
139
  capsys.readouterr()
137
140
  assert rc == 1
138
141
 
@@ -193,13 +196,14 @@ def test_provider_api_key_local_mode_when_ollama_running_no_config(tmp_path: Pat
193
196
  def test_provider_api_key_hint_when_ollama_absent_no_config(tmp_path: Path):
194
197
  from unittest.mock import patch
195
198
  repo = _copy_repo_layout(tmp_path)
196
- # no forerunner.config.yaml
197
- with patch("codeforerunner.providers.ollama.is_available", return_value=False):
199
+ # no forerunner.config.yaml; no skill installed → fallback message mentions prompt-only
200
+ with patch("codeforerunner.providers.ollama.is_available", return_value=False), \
201
+ patch("codeforerunner.doctor._skill_mode_active", return_value=False):
198
202
  findings = run(repo)
199
203
  matches = [f for f in findings if f.check == "provider-api-key"]
200
204
  assert len(matches) == 1
201
205
  assert matches[0].severity == "ok"
202
- assert "Ollama" in matches[0].message
206
+ assert "prompt-only" in matches[0].message
203
207
 
204
208
 
205
209
  def test_provider_api_key_ollama_config_shows_local_mode(tmp_path: Path, monkeypatch):
@@ -127,7 +127,8 @@ def test_tools_call_unknown(server: _Server) -> None:
127
127
 
128
128
 
129
129
  def test_unknown_method(server: _Server) -> None:
130
- resp = server.request({"jsonrpc": "2.0", "id": 1, "method": "no/such/method", "params": {}})
130
+ server.request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
131
+ resp = server.request({"jsonrpc": "2.0", "id": 2, "method": "no/such/method", "params": {}})
131
132
  assert "error" in resp
132
133
  assert resp["error"]["code"] == -32601
133
134
 
File without changes