codeforerunner 0.4.1__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.
- {codeforerunner-0.4.1/src/codeforerunner.egg-info → codeforerunner-0.4.2}/PKG-INFO +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/README.md +1 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/pyproject.toml +1 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/bundle.py +2 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/cli.py +60 -27
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/config.py +11 -9
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/doctor.py +74 -25
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/installer.py +8 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/mcp_server.py +15 -7
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/anthropic.py +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/google.py +4 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/ollama.py +26 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/openai.py +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2/src/codeforerunner.egg-info}/PKG-INFO +2 -2
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_cli.py +94 -5
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_config.py +0 -4
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_doctor.py +11 -7
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_mcp_server.py +2 -1
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/LICENSE.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/setup.cfg +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/__init__.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/check.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/context-format.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/system/base.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/audit.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/check.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/flows.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/readme.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/review.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/scan.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/__init__.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/providers/base.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/SOURCES.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/entry_points.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/requires.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/top_level.txt +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_check.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_check_config_integration.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_examples.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_hooks_manifest.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_installer.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_providers.py +0 -0
- {codeforerunner-0.4.1 → codeforerunner-0.4.2}/tests/test_validate_codex_marketplace.py +0 -0
- {codeforerunner-0.4.1 → 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.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
|
|
5
5
|
Author: Derek Palmer
|
|
6
6
|
License-Expression: LicenseRef-Codeforerunner-SAL-0.1
|
|
@@ -175,7 +175,7 @@ forerunner check # run any time or as pre-commit hook
|
|
|
175
175
|
## GitHub Action
|
|
176
176
|
|
|
177
177
|
```yaml
|
|
178
|
-
- uses: derek-palmer/codeforerunner@v0.
|
|
178
|
+
- uses: derek-palmer/codeforerunner@v0.4.2
|
|
179
179
|
```
|
|
180
180
|
|
|
181
181
|
No-op when `forerunner.config.yaml` is absent.
|
|
@@ -25,7 +25,8 @@ def find_prompts_root(repo_arg: str | Path | None = None) -> Path:
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
here = Path.cwd().resolve()
|
|
28
|
-
|
|
28
|
+
parents = list(here.parents)
|
|
29
|
+
for candidate in [here, *parents[:10]]:
|
|
29
30
|
if (candidate / "prompts" / "tasks").is_dir():
|
|
30
31
|
return candidate / "prompts"
|
|
31
32
|
|
|
@@ -8,24 +8,24 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Sequence
|
|
10
10
|
|
|
11
|
-
from codeforerunner.bundle import find_prompts_root, resolve_bundle
|
|
11
|
+
from codeforerunner.bundle import find_prompts_root, resolve_bundle as _resolve_bundle
|
|
12
12
|
|
|
13
13
|
SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
|
|
14
14
|
SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def
|
|
18
|
-
"""Resolve
|
|
17
|
+
def _get_bundle(args: argparse.Namespace) -> tuple[str, int]:
|
|
18
|
+
"""Resolve bundle for args.task. Returns (bundle_text, exit_code). exit_code != 0 on error."""
|
|
19
19
|
try:
|
|
20
20
|
prompts_root = find_prompts_root(args.repo)
|
|
21
21
|
except FileNotFoundError as e:
|
|
22
22
|
print(f"error: {e}", file=sys.stderr)
|
|
23
|
-
return 2
|
|
23
|
+
return "", 2
|
|
24
24
|
|
|
25
25
|
task_path = prompts_root / "tasks" / f"{args.task}.md"
|
|
26
26
|
if not task_path.is_file():
|
|
27
27
|
print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
|
|
28
|
-
return 2
|
|
28
|
+
return "", 2
|
|
29
29
|
|
|
30
30
|
repo_root = Path(args.repo) if args.repo else Path.cwd()
|
|
31
31
|
if (
|
|
@@ -40,10 +40,18 @@ def cmd_doc(args: argparse.Namespace) -> int:
|
|
|
40
40
|
)
|
|
41
41
|
|
|
42
42
|
try:
|
|
43
|
-
|
|
43
|
+
return _resolve_bundle(prompts_root, args.task), 0
|
|
44
44
|
except FileNotFoundError as e:
|
|
45
45
|
print(f"error: {e}", file=sys.stderr)
|
|
46
|
-
return 2
|
|
46
|
+
return "", 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_doc(args: argparse.Namespace) -> int:
|
|
50
|
+
"""Resolve base + partials + task bundle to stdout."""
|
|
51
|
+
bundle, rc = _get_bundle(args)
|
|
52
|
+
if rc != 0:
|
|
53
|
+
return rc
|
|
54
|
+
sys.stdout.write(bundle)
|
|
47
55
|
return 0
|
|
48
56
|
|
|
49
57
|
|
|
@@ -104,13 +112,27 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
|
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def cmd_generate(args: argparse.Namespace) -> int:
|
|
107
|
-
"""Resolve the bundle for <task> and send it to the configured provider.
|
|
115
|
+
"""Resolve the bundle for <task> and send it to the configured provider.
|
|
116
|
+
|
|
117
|
+
With --prompt-only (or when no provider/key/Ollama is reachable), outputs
|
|
118
|
+
the assembled prompt bundle to stdout for the calling agent to process.
|
|
119
|
+
"""
|
|
108
120
|
from codeforerunner import providers as _providers
|
|
109
121
|
from codeforerunner.config import load_from_repo
|
|
110
122
|
|
|
111
123
|
repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
112
124
|
cfg = load_from_repo(repo_root)
|
|
113
125
|
|
|
126
|
+
ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
|
|
127
|
+
|
|
128
|
+
# --prompt-only: output the bundle and stop; the calling agent is the model.
|
|
129
|
+
if getattr(args, "prompt_only", False):
|
|
130
|
+
return cmd_doc(ns)
|
|
131
|
+
|
|
132
|
+
bundle, rc = _get_bundle(ns)
|
|
133
|
+
if rc != 0:
|
|
134
|
+
return rc
|
|
135
|
+
|
|
114
136
|
explicit_provider = args.provider or (cfg.provider if cfg else None)
|
|
115
137
|
provider_name = explicit_provider or "anthropic"
|
|
116
138
|
model = args.model or (cfg.model if cfg else None)
|
|
@@ -118,18 +140,6 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
118
140
|
provider = provider_cls()
|
|
119
141
|
model = model or provider.default_model
|
|
120
142
|
|
|
121
|
-
import io as _io
|
|
122
|
-
buf = _io.StringIO()
|
|
123
|
-
ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
|
|
124
|
-
real_stdout = sys.stdout
|
|
125
|
-
sys.stdout = buf
|
|
126
|
-
try:
|
|
127
|
-
rc = cmd_doc(ns)
|
|
128
|
-
finally:
|
|
129
|
-
sys.stdout = real_stdout
|
|
130
|
-
if rc != 0:
|
|
131
|
-
return rc
|
|
132
|
-
|
|
133
143
|
env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
|
|
134
144
|
api_key = os.environ.get(env_var)
|
|
135
145
|
if api_key is None and provider_name != "ollama":
|
|
@@ -140,16 +150,26 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
140
150
|
if not args.model:
|
|
141
151
|
model = provider.default_model
|
|
142
152
|
print("info: no API key; falling back to Ollama (local mode)", file=sys.stderr)
|
|
153
|
+
elif explicit_provider is None:
|
|
154
|
+
# Skill-mode auto-detect: no provider configured, no Ollama — output
|
|
155
|
+
# the prompt bundle for the calling agent to process directly.
|
|
156
|
+
sys.stdout.write(bundle)
|
|
157
|
+
if sys.stdout.isatty():
|
|
158
|
+
print(
|
|
159
|
+
"\ninfo: no provider configured and Ollama not running.\n"
|
|
160
|
+
" Prompt bundle written above — paste into your agent,\n"
|
|
161
|
+
" or run: forerunner generate --prompt-only "
|
|
162
|
+
f"{args.task}",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
)
|
|
165
|
+
return 0
|
|
143
166
|
else:
|
|
144
|
-
|
|
145
|
-
if explicit_provider is None:
|
|
146
|
-
msg += "\nhint: start Ollama for keyless local generation (https://ollama.com)"
|
|
147
|
-
print(msg, file=sys.stderr)
|
|
167
|
+
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
|
|
148
168
|
return 3
|
|
149
169
|
|
|
150
170
|
if getattr(args, "stream", False):
|
|
151
171
|
try:
|
|
152
|
-
for chunk in provider.stream(prompt=
|
|
172
|
+
for chunk in provider.stream(prompt=bundle, model=model, api_key=api_key):
|
|
153
173
|
sys.stdout.write(chunk)
|
|
154
174
|
sys.stdout.flush()
|
|
155
175
|
except _providers.ProviderError as e:
|
|
@@ -159,7 +179,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
159
179
|
return 0
|
|
160
180
|
|
|
161
181
|
try:
|
|
162
|
-
result = provider.complete(prompt=
|
|
182
|
+
result = provider.complete(prompt=bundle, model=model, api_key=api_key)
|
|
163
183
|
except _providers.ProviderError as e:
|
|
164
184
|
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
165
185
|
return 4
|
|
@@ -183,7 +203,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
183
203
|
print(f"wrote {cfg_path}", file=sys.stderr)
|
|
184
204
|
else:
|
|
185
205
|
print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
|
|
186
|
-
findings = doctor.run(root)
|
|
206
|
+
findings = doctor.run(root, run_scripts=getattr(args, "run_scripts", False))
|
|
187
207
|
sys.stdout.write(doctor.format_report(findings) + "\n")
|
|
188
208
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
189
209
|
|
|
@@ -236,6 +256,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
236
256
|
action="store_true",
|
|
237
257
|
help="write a starter forerunner.config.yaml if absent",
|
|
238
258
|
)
|
|
259
|
+
s_doctor.add_argument(
|
|
260
|
+
"--run-scripts",
|
|
261
|
+
dest="run_scripts",
|
|
262
|
+
action="store_true",
|
|
263
|
+
default=False,
|
|
264
|
+
help="allow executing Python scripts from the target repo to validate skill copies (off by default)",
|
|
265
|
+
)
|
|
239
266
|
s_doctor.set_defaults(func=cmd_doctor)
|
|
240
267
|
|
|
241
268
|
s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
|
|
@@ -243,6 +270,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
243
270
|
s_gen.add_argument("--provider", help="override config provider")
|
|
244
271
|
s_gen.add_argument("--model", help="override config model")
|
|
245
272
|
s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
|
|
273
|
+
s_gen.add_argument(
|
|
274
|
+
"--prompt-only",
|
|
275
|
+
dest="prompt_only",
|
|
276
|
+
action="store_true",
|
|
277
|
+
help="output the assembled prompt bundle to stdout; do not call a model (skill mode)",
|
|
278
|
+
)
|
|
246
279
|
s_gen.set_defaults(func=cmd_generate)
|
|
247
280
|
|
|
248
281
|
from codeforerunner import installer
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
from pathlib import Path
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
9
8
|
import yaml
|
|
@@ -37,9 +36,6 @@ class VersionAuditConfig:
|
|
|
37
36
|
class ForerunnerConfig:
|
|
38
37
|
provider: str = "anthropic"
|
|
39
38
|
model: str = "claude-opus-4-7"
|
|
40
|
-
output_dir: Path = field(default_factory=lambda: Path("docs"))
|
|
41
|
-
context_max_files: int = 30
|
|
42
|
-
context_max_lines_per_file: int = 300
|
|
43
39
|
approaching_eol_threshold_months: int = 6
|
|
44
40
|
ignore_patterns: tuple[str, ...] = ()
|
|
45
41
|
api_key_env: dict[str, str] = field(default_factory=dict)
|
|
@@ -117,13 +113,20 @@ def _parse_check(raw: Any) -> CheckConfig:
|
|
|
117
113
|
)
|
|
118
114
|
|
|
119
115
|
|
|
116
|
+
def _to_int(value: Any, field_name: str) -> int:
|
|
117
|
+
try:
|
|
118
|
+
return int(value)
|
|
119
|
+
except (TypeError, ValueError) as e:
|
|
120
|
+
raise ConfigError(f"{field_name}: expected integer, got {value!r}") from e
|
|
121
|
+
|
|
122
|
+
|
|
120
123
|
def _parse_version_audit(raw: Any) -> VersionAuditConfig:
|
|
121
124
|
if raw is None:
|
|
122
125
|
return VersionAuditConfig()
|
|
123
126
|
_require_type(raw, dict, "tasks.version_audit")
|
|
124
127
|
return VersionAuditConfig(
|
|
125
128
|
enabled=bool(raw.get("enabled", True)),
|
|
126
|
-
stale_after_days=
|
|
129
|
+
stale_after_days=_to_int(raw.get("stale_after_days", 30), "tasks.version_audit.stale_after_days"),
|
|
127
130
|
fetch_live_eol_data=bool(raw.get("fetch_live_eol_data", False)),
|
|
128
131
|
)
|
|
129
132
|
|
|
@@ -147,10 +150,9 @@ def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
|
|
|
147
150
|
return ForerunnerConfig(
|
|
148
151
|
provider=provider,
|
|
149
152
|
model=_require_type(raw.get("model", "claude-opus-4-7"), str, "model"),
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
approaching_eol_threshold_months=int(raw.get("approaching_eol_threshold_months", 6)),
|
|
153
|
+
approaching_eol_threshold_months=_to_int(
|
|
154
|
+
raw.get("approaching_eol_threshold_months", 6), "approaching_eol_threshold_months"
|
|
155
|
+
),
|
|
154
156
|
ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
|
|
155
157
|
api_key_env=_parse_api_key_env(raw.get("api_key_env")),
|
|
156
158
|
check=_parse_check(tasks.get("check")),
|
|
@@ -6,6 +6,7 @@ import argparse
|
|
|
6
6
|
import importlib.util
|
|
7
7
|
import os
|
|
8
8
|
import sys
|
|
9
|
+
import uuid
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Callable
|
|
@@ -50,17 +51,27 @@ def _installed_marketplace_destination() -> Path:
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def _load_script_module(repo: Path, relpath: str, module_name: str):
|
|
54
|
+
# L3: unique name prevents stale cached module on repeated calls
|
|
55
|
+
unique_name = f"{module_name}_{uuid.uuid4().hex}"
|
|
53
56
|
script_path = repo / relpath
|
|
54
|
-
spec = importlib.util.spec_from_file_location(
|
|
57
|
+
spec = importlib.util.spec_from_file_location(unique_name, script_path)
|
|
55
58
|
if spec is None or spec.loader is None: # pragma: no cover - defensive
|
|
56
59
|
raise RuntimeError(f"cannot load {script_path}")
|
|
57
60
|
module = importlib.util.module_from_spec(spec)
|
|
58
|
-
sys.modules[
|
|
61
|
+
sys.modules[unique_name] = module
|
|
59
62
|
spec.loader.exec_module(module)
|
|
60
63
|
return module
|
|
61
64
|
|
|
62
65
|
|
|
63
|
-
def _check_skill_body_parity(repo: Path) -> list[Finding]:
|
|
66
|
+
def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
67
|
+
if not run_scripts:
|
|
68
|
+
return [
|
|
69
|
+
Finding(
|
|
70
|
+
"warn",
|
|
71
|
+
"skill-body-parity",
|
|
72
|
+
"skipping script validation (pass --run-scripts to allow executing repo scripts)",
|
|
73
|
+
)
|
|
74
|
+
]
|
|
64
75
|
try:
|
|
65
76
|
skill_mod = _load_script_module(
|
|
66
77
|
repo, "scripts/validate_skill_copies.py", "_forerunner_doctor_skill_copies"
|
|
@@ -104,7 +115,15 @@ def _check_skill_body_parity(repo: Path) -> list[Finding]:
|
|
|
104
115
|
return findings
|
|
105
116
|
|
|
106
117
|
|
|
107
|
-
def _check_codex_marketplace(repo: Path) -> list[Finding]:
|
|
118
|
+
def _check_codex_marketplace(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
119
|
+
if not run_scripts:
|
|
120
|
+
return [
|
|
121
|
+
Finding(
|
|
122
|
+
"warn",
|
|
123
|
+
"codex-marketplace",
|
|
124
|
+
"skipping script validation (pass --run-scripts to allow executing repo scripts)",
|
|
125
|
+
)
|
|
126
|
+
]
|
|
108
127
|
try:
|
|
109
128
|
mp_mod = _load_script_module(
|
|
110
129
|
repo,
|
|
@@ -224,6 +243,19 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
|
224
243
|
return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
|
|
225
244
|
|
|
226
245
|
|
|
246
|
+
def _skill_mode_active() -> bool:
|
|
247
|
+
"""True if any installed skill destination has managed markers — agent IS the model."""
|
|
248
|
+
for dest in _installed_skill_destinations():
|
|
249
|
+
if dest.exists():
|
|
250
|
+
try:
|
|
251
|
+
text = dest.read_text(encoding="utf-8")
|
|
252
|
+
if MARKER_BEGIN in text and MARKER_END in text:
|
|
253
|
+
return True
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
227
259
|
def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
228
260
|
from codeforerunner.providers.ollama import is_available as _ollama_available
|
|
229
261
|
|
|
@@ -237,11 +269,19 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
237
269
|
"no config; Ollama running — generate will use local mode automatically",
|
|
238
270
|
)
|
|
239
271
|
]
|
|
272
|
+
if _skill_mode_active():
|
|
273
|
+
return [
|
|
274
|
+
Finding(
|
|
275
|
+
"ok",
|
|
276
|
+
"provider-api-key",
|
|
277
|
+
"no config; skill mode active — the installed agent is the model, no API key needed",
|
|
278
|
+
)
|
|
279
|
+
]
|
|
240
280
|
return [
|
|
241
281
|
Finding(
|
|
242
282
|
"ok",
|
|
243
283
|
"provider-api-key",
|
|
244
|
-
f"no {CONFIG_FILENAME}; set an API key in config
|
|
284
|
+
f"no {CONFIG_FILENAME}; set an API key in config, start Ollama, or use skill mode (`forerunner generate --prompt-only`)",
|
|
245
285
|
)
|
|
246
286
|
]
|
|
247
287
|
try:
|
|
@@ -279,7 +319,12 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
279
319
|
Finding(
|
|
280
320
|
"warn",
|
|
281
321
|
"provider-api-key",
|
|
282
|
-
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run"
|
|
322
|
+
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run"
|
|
323
|
+
+ (
|
|
324
|
+
" (use `--prompt-only` for key-free bundle output)"
|
|
325
|
+
if _skill_mode_active()
|
|
326
|
+
else ""
|
|
327
|
+
),
|
|
283
328
|
)
|
|
284
329
|
]
|
|
285
330
|
|
|
@@ -288,15 +333,17 @@ _STARTER_CONFIG = """\
|
|
|
288
333
|
# forerunner.config.yaml — generated by `forerunner doctor --fix`
|
|
289
334
|
# See https://github.com/derek-palmer/codeforerunner for docs.
|
|
290
335
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
336
|
+
tasks:
|
|
337
|
+
check:
|
|
338
|
+
enabled_rules:
|
|
339
|
+
- R1-no-cli
|
|
340
|
+
- R2-no-pre-commit
|
|
341
|
+
- R3-no-ci
|
|
342
|
+
- R4-no-installer
|
|
343
|
+
- R5-no-python-package
|
|
344
|
+
- R7-no-mcp
|
|
345
|
+
- R8-no-marketplace
|
|
346
|
+
ignore_paths: []
|
|
300
347
|
"""
|
|
301
348
|
|
|
302
349
|
|
|
@@ -304,11 +351,11 @@ def starter_config() -> str:
|
|
|
304
351
|
return _STARTER_CONFIG
|
|
305
352
|
|
|
306
353
|
|
|
307
|
-
def run(repo: Path) -> list[Finding]:
|
|
354
|
+
def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
308
355
|
repo = repo.resolve()
|
|
309
356
|
findings: list[Finding] = []
|
|
310
|
-
findings.extend(_check_skill_body_parity(repo))
|
|
311
|
-
findings.extend(_check_codex_marketplace(repo))
|
|
357
|
+
findings.extend(_check_skill_body_parity(repo, run_scripts=run_scripts))
|
|
358
|
+
findings.extend(_check_codex_marketplace(repo, run_scripts=run_scripts))
|
|
312
359
|
findings.extend(_check_installed_destinations(repo))
|
|
313
360
|
findings.extend(_check_config_loadable(repo))
|
|
314
361
|
findings.extend(_check_provider_api_key(repo))
|
|
@@ -317,14 +364,10 @@ def run(repo: Path) -> list[Finding]:
|
|
|
317
364
|
|
|
318
365
|
def format_report(findings: list[Finding]) -> str:
|
|
319
366
|
lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
|
|
320
|
-
counts = {"ok": 0, "warn": 0, "error": 0}
|
|
367
|
+
counts: dict[str, int] = {"ok": 0, "warn": 0, "error": 0}
|
|
321
368
|
for f in findings:
|
|
322
369
|
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
323
|
-
summary =
|
|
324
|
-
f"summary: {counts.get('ok', 0)} ok, "
|
|
325
|
-
f"{counts.get('warn', 0)} warn, "
|
|
326
|
-
f"{counts.get('error', 0)} error"
|
|
327
|
-
)
|
|
370
|
+
summary = f"summary: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error"
|
|
328
371
|
lines.append(summary)
|
|
329
372
|
return "\n".join(lines)
|
|
330
373
|
|
|
@@ -340,9 +383,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
340
383
|
default=Path.cwd(),
|
|
341
384
|
help="repo root (default: cwd)",
|
|
342
385
|
)
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--run-scripts",
|
|
388
|
+
action="store_true",
|
|
389
|
+
default=False,
|
|
390
|
+
help="allow executing Python scripts from the target repo (off by default for safety)",
|
|
391
|
+
)
|
|
343
392
|
args = parser.parse_args(argv)
|
|
344
393
|
|
|
345
|
-
findings = run(args.repo)
|
|
394
|
+
findings = run(args.repo, run_scripts=args.run_scripts)
|
|
346
395
|
print(format_report(findings))
|
|
347
396
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
348
397
|
|
|
@@ -95,6 +95,7 @@ def install_all_skills(
|
|
|
95
95
|
src_path = repo_root / "plugins" / "codeforerunner" / "skills" / slug / "SKILL.md"
|
|
96
96
|
if not src_path.is_file():
|
|
97
97
|
print(f"warning: skill source not found: {src_path}", file=err)
|
|
98
|
+
any_error = True
|
|
98
99
|
continue
|
|
99
100
|
try:
|
|
100
101
|
target = resolve_skill_target(agent, slug)
|
|
@@ -116,7 +117,11 @@ def install_all_skills(
|
|
|
116
117
|
print(f"{prefix}{action}: {dest}", file=out)
|
|
117
118
|
if not check_only:
|
|
118
119
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
120
|
+
try:
|
|
121
|
+
dest.write_bytes(src_path.read_bytes())
|
|
122
|
+
except OSError as e:
|
|
123
|
+
print(f"error: failed to write {dest}: {e}", file=err)
|
|
124
|
+
any_error = True
|
|
120
125
|
return EXIT_OK if not any_error else EXIT_BODY_MISMATCH
|
|
121
126
|
|
|
122
127
|
|
|
@@ -188,7 +193,8 @@ def find_markers(text: str) -> tuple[int, int] | None:
|
|
|
188
193
|
def overlay(dest_text: str, source_body: str) -> str:
|
|
189
194
|
"""Replace managed region in-place. Caller has verified markers exist."""
|
|
190
195
|
span = find_markers(dest_text)
|
|
191
|
-
|
|
196
|
+
if span is None:
|
|
197
|
+
raise RuntimeError("overlay: span is None — this is a bug")
|
|
192
198
|
a, b = span
|
|
193
199
|
managed = f"{MARKER_BEGIN}\n{source_body}\n{MARKER_END}"
|
|
194
200
|
return dest_text[:a] + managed + dest_text[b:]
|
|
@@ -27,11 +27,12 @@ def _list_tasks(prompts_root: Path) -> list[Path]:
|
|
|
27
27
|
|
|
28
28
|
def _description_for(task_path: Path) -> str:
|
|
29
29
|
"""First non-empty markdown line, stripped of leading '#' and whitespace."""
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
with task_path.open(encoding="utf-8") as f:
|
|
31
|
+
for raw in f:
|
|
32
|
+
line = raw.strip()
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
return line.lstrip("#").strip()
|
|
35
36
|
return task_path.stem
|
|
36
37
|
|
|
37
38
|
|
|
@@ -68,6 +69,7 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
68
69
|
return None
|
|
69
70
|
|
|
70
71
|
if method == "initialize":
|
|
72
|
+
state["initialized"] = True
|
|
71
73
|
return _ok(
|
|
72
74
|
req_id,
|
|
73
75
|
{
|
|
@@ -77,13 +79,19 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
77
79
|
},
|
|
78
80
|
)
|
|
79
81
|
|
|
82
|
+
if not state.get("initialized"):
|
|
83
|
+
return _err(req_id, -32002, "Server not initialized")
|
|
84
|
+
|
|
80
85
|
if method == "tools/list":
|
|
81
86
|
return _ok(req_id, {"tools": _tools(prompts_root)})
|
|
82
87
|
|
|
83
88
|
if method == "tools/call":
|
|
84
89
|
name = params.get("name")
|
|
90
|
+
if not isinstance(name, str) or "/" in name or "\\" in name or ".." in name:
|
|
91
|
+
return _err(req_id, -32602, f"invalid tool name: {name!r}")
|
|
85
92
|
task_path = prompts_root / "tasks" / f"{name}.md"
|
|
86
|
-
|
|
93
|
+
tasks_root = (prompts_root / "tasks").resolve()
|
|
94
|
+
if not task_path.resolve().is_relative_to(tasks_root) or not task_path.is_file():
|
|
87
95
|
return _err(req_id, -32602, f"unknown tool: {name!r}")
|
|
88
96
|
if name not in SCAN_EXEMPT_TOOLS and not state.get("scan_called"):
|
|
89
97
|
return _err(
|
|
@@ -106,7 +114,7 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
106
114
|
|
|
107
115
|
|
|
108
116
|
def serve(prompts_root: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdout, stderr=sys.stderr) -> int:
|
|
109
|
-
state: dict[str, Any] = {"scan_called": False}
|
|
117
|
+
state: dict[str, Any] = {"scan_called": False, "initialized": False}
|
|
110
118
|
for raw in stdin:
|
|
111
119
|
line = raw.strip()
|
|
112
120
|
if not line:
|
|
@@ -45,7 +45,7 @@ class AnthropicProvider:
|
|
|
45
45
|
},
|
|
46
46
|
)
|
|
47
47
|
try:
|
|
48
|
-
with urllib.request.urlopen(req) as resp:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
49
49
|
raw = resp.read()
|
|
50
50
|
except urllib.error.HTTPError as e:
|
|
51
51
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -90,7 +90,7 @@ class AnthropicProvider:
|
|
|
90
90
|
},
|
|
91
91
|
)
|
|
92
92
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
93
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
94
|
except urllib.error.HTTPError as e:
|
|
95
95
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
96
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -16,6 +16,8 @@ class GoogleProvider:
|
|
|
16
16
|
default_env_var = "GOOGLE_API_KEY"
|
|
17
17
|
default_model = "gemini-2.5-pro"
|
|
18
18
|
|
|
19
|
+
# Google REST API requires the key in the URL query string — this is the documented
|
|
20
|
+
# mechanism and cannot be changed. Be aware the key may appear in proxy/server logs.
|
|
19
21
|
endpoint_template = (
|
|
20
22
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
21
23
|
)
|
|
@@ -48,7 +50,7 @@ class GoogleProvider:
|
|
|
48
50
|
headers={"content-type": "application/json"},
|
|
49
51
|
)
|
|
50
52
|
try:
|
|
51
|
-
with urllib.request.urlopen(req) as resp:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
52
54
|
raw = resp.read()
|
|
53
55
|
except urllib.error.HTTPError as e:
|
|
54
56
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -90,7 +92,7 @@ class GoogleProvider:
|
|
|
90
92
|
headers={"content-type": "application/json"},
|
|
91
93
|
)
|
|
92
94
|
try:
|
|
93
|
-
resp = urllib.request.urlopen(req)
|
|
95
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
94
96
|
except urllib.error.HTTPError as e:
|
|
95
97
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
98
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
8
9
|
import urllib.request
|
|
9
10
|
from typing import Iterator
|
|
10
11
|
|
|
@@ -12,11 +13,32 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
12
13
|
|
|
13
14
|
DEFAULT_HOST = "http://localhost:11434"
|
|
14
15
|
|
|
16
|
+
_SAFE_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_ollama_base(base: str) -> None:
|
|
20
|
+
"""Reject URLs that look like cloud metadata endpoints or use unexpected schemes."""
|
|
21
|
+
try:
|
|
22
|
+
parsed = urllib.parse.urlparse(base)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise ValueError(f"OLLAMA_HOST: invalid URL: {e}") from e
|
|
25
|
+
if parsed.scheme not in ("http", "https"):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"OLLAMA_HOST: scheme must be http or https, got {parsed.scheme!r}"
|
|
28
|
+
)
|
|
29
|
+
host = parsed.hostname or ""
|
|
30
|
+
if "169.254." in host:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"OLLAMA_HOST: refusing connection to link-local address {host!r} "
|
|
33
|
+
"(looks like a cloud metadata endpoint)"
|
|
34
|
+
)
|
|
35
|
+
|
|
15
36
|
|
|
16
37
|
def is_available(host: str | None = None) -> bool:
|
|
17
38
|
"""Return True if an Ollama instance is reachable at the configured host."""
|
|
18
39
|
base = (host or os.environ.get("OLLAMA_HOST") or DEFAULT_HOST).rstrip("/")
|
|
19
40
|
try:
|
|
41
|
+
_validate_ollama_base(base)
|
|
20
42
|
urllib.request.urlopen(f"{base}/api/tags", timeout=2)
|
|
21
43
|
return True
|
|
22
44
|
except Exception:
|
|
@@ -38,6 +60,7 @@ class OllamaProvider:
|
|
|
38
60
|
# api_key is interpreted as a base URL override; fall back to env then default.
|
|
39
61
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
40
62
|
base = base.rstrip("/")
|
|
63
|
+
_validate_ollama_base(base)
|
|
41
64
|
model = model or self.default_model
|
|
42
65
|
url = f"{base}/api/generate"
|
|
43
66
|
body = json.dumps(
|
|
@@ -50,7 +73,7 @@ class OllamaProvider:
|
|
|
50
73
|
headers={"content-type": "application/json"},
|
|
51
74
|
)
|
|
52
75
|
try:
|
|
53
|
-
with urllib.request.urlopen(req) as resp:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
54
77
|
raw = resp.read()
|
|
55
78
|
except urllib.error.HTTPError as e:
|
|
56
79
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -75,6 +98,7 @@ class OllamaProvider:
|
|
|
75
98
|
) -> Iterator[str]:
|
|
76
99
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
77
100
|
base = base.rstrip("/")
|
|
101
|
+
_validate_ollama_base(base)
|
|
78
102
|
model = model or self.default_model
|
|
79
103
|
url = f"{base}/api/generate"
|
|
80
104
|
body = json.dumps(
|
|
@@ -87,7 +111,7 @@ class OllamaProvider:
|
|
|
87
111
|
headers={"content-type": "application/json"},
|
|
88
112
|
)
|
|
89
113
|
try:
|
|
90
|
-
resp = urllib.request.urlopen(req)
|
|
114
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
91
115
|
except urllib.error.HTTPError as e:
|
|
92
116
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
93
117
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -43,7 +43,7 @@ class OpenAIProvider:
|
|
|
43
43
|
},
|
|
44
44
|
)
|
|
45
45
|
try:
|
|
46
|
-
with urllib.request.urlopen(req) as resp:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
47
47
|
raw = resp.read()
|
|
48
48
|
except urllib.error.HTTPError as e:
|
|
49
49
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
@@ -86,7 +86,7 @@ class OpenAIProvider:
|
|
|
86
86
|
},
|
|
87
87
|
)
|
|
88
88
|
try:
|
|
89
|
-
resp = urllib.request.urlopen(req)
|
|
89
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
90
90
|
except urllib.error.HTTPError as e:
|
|
91
91
|
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
92
92
|
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeforerunner
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
|
|
5
5
|
Author: Derek Palmer
|
|
6
6
|
License-Expression: LicenseRef-Codeforerunner-SAL-0.1
|
|
@@ -175,7 +175,7 @@ forerunner check # run any time or as pre-commit hook
|
|
|
175
175
|
## GitHub Action
|
|
176
176
|
|
|
177
177
|
```yaml
|
|
178
|
-
- uses: derek-palmer/codeforerunner@v0.
|
|
178
|
+
- uses: derek-palmer/codeforerunner@v0.4.2
|
|
179
179
|
```
|
|
180
180
|
|
|
181
181
|
No-op when `forerunner.config.yaml` is absent.
|
|
@@ -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
|
|
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 →
|
|
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 ==
|
|
411
|
-
assert "
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/context-format.md
RENAMED
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/output-rules.md
RENAMED
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/partials/stack-hints.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/stack-docs.md
RENAMED
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner/prompts/tasks/version-audit.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.4.1 → codeforerunner-0.4.2}/src/codeforerunner.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|