codeforerunner 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. codeforerunner/__init__.py +6 -1
  2. codeforerunner/bundle.py +58 -0
  3. codeforerunner/check.py +116 -23
  4. codeforerunner/cli.py +63 -48
  5. codeforerunner/doctor.py +20 -0
  6. codeforerunner/installer.py +1 -3
  7. codeforerunner/mcp_server.py +18 -50
  8. codeforerunner/prompts/partials/context-format.md +29 -0
  9. codeforerunner/prompts/partials/output-rules.md +39 -0
  10. codeforerunner/prompts/partials/stack-hints.md +43 -0
  11. codeforerunner/prompts/system/base.md +38 -0
  12. codeforerunner/prompts/tasks/api-docs.md +47 -0
  13. codeforerunner/prompts/tasks/audit.md +44 -0
  14. codeforerunner/prompts/tasks/changelog.md +42 -0
  15. codeforerunner/prompts/tasks/check.md +61 -0
  16. codeforerunner/prompts/tasks/diagrams.md +66 -0
  17. codeforerunner/prompts/tasks/flows.md +55 -0
  18. codeforerunner/prompts/tasks/init-agent-onboarding.md +48 -0
  19. codeforerunner/prompts/tasks/readme.md +34 -0
  20. codeforerunner/prompts/tasks/review.md +48 -0
  21. codeforerunner/prompts/tasks/scan.md +92 -0
  22. codeforerunner/prompts/tasks/stack-docs.md +50 -0
  23. codeforerunner/prompts/tasks/version-audit.md +114 -0
  24. codeforerunner/providers/anthropic.py +57 -0
  25. codeforerunner/providers/base.py +9 -1
  26. codeforerunner/providers/google.py +50 -0
  27. codeforerunner/providers/ollama.py +45 -0
  28. codeforerunner/providers/openai.py +54 -0
  29. {codeforerunner-0.3.0.dist-info → codeforerunner-0.3.2.dist-info}/METADATA +14 -1
  30. codeforerunner-0.3.2.dist-info/RECORD +36 -0
  31. codeforerunner-0.3.0.dist-info/RECORD +0 -19
  32. {codeforerunner-0.3.0.dist-info → codeforerunner-0.3.2.dist-info}/WHEEL +0 -0
  33. {codeforerunner-0.3.0.dist-info → codeforerunner-0.3.2.dist-info}/entry_points.txt +0 -0
  34. {codeforerunner-0.3.0.dist-info → codeforerunner-0.3.2.dist-info}/licenses/LICENSE.md +0 -0
  35. {codeforerunner-0.3.0.dist-info → codeforerunner-0.3.2.dist-info}/top_level.txt +0 -0
@@ -1 +1,6 @@
1
- __version__ = "0.2.0"
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("codeforerunner")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0" # running from source without install
@@ -0,0 +1,58 @@
1
+ """Shared prompt resolution used by cli.py and mcp_server.py."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def _package_prompts() -> Path:
8
+ return Path(__file__).parent / "prompts"
9
+
10
+
11
+ def find_prompts_root(repo_arg: str | Path | None = None) -> Path:
12
+ """Return the prompts root directory (parent of tasks/).
13
+
14
+ Resolution order:
15
+ 1. {repo_arg}/prompts/ if given and contains tasks/
16
+ 2. Walk up from cwd looking for prompts/tasks/ (checkout compat)
17
+ 3. Package-bundled prompts (always available after pip install)
18
+ """
19
+ if repo_arg is not None:
20
+ p = Path(repo_arg) / "prompts"
21
+ if (p / "tasks").is_dir():
22
+ return p
23
+ raise FileNotFoundError(
24
+ f"no prompts/tasks/ found under {str(repo_arg)!r}"
25
+ )
26
+
27
+ here = Path.cwd().resolve()
28
+ for candidate in [here, *here.parents]:
29
+ if (candidate / "prompts" / "tasks").is_dir():
30
+ return candidate / "prompts"
31
+
32
+ pkg = _package_prompts()
33
+ if (pkg / "tasks").is_dir():
34
+ return pkg
35
+
36
+ raise FileNotFoundError(
37
+ "could not find prompts/tasks/; specify --repo or reinstall the package"
38
+ )
39
+
40
+
41
+ def resolve_bundle(prompts_root: Path, task: str) -> str:
42
+ """Concatenate system/base.md + sorted partials/*.md + tasks/<task>.md."""
43
+ task_path = prompts_root / "tasks" / f"{task}.md"
44
+ if not task_path.is_file():
45
+ raise FileNotFoundError(f"unknown task {task!r} (no {task_path})")
46
+
47
+ parts: list[str] = []
48
+ base = prompts_root / "system" / "base.md"
49
+ if base.is_file():
50
+ parts.append(f"<!-- system: base.md -->\n{base.read_text(encoding='utf-8').rstrip()}")
51
+
52
+ partials_dir = prompts_root / "partials"
53
+ if partials_dir.is_dir():
54
+ for p in sorted(partials_dir.glob("*.md")):
55
+ parts.append(f"<!-- partial: {p.name} -->\n{p.read_text(encoding='utf-8').rstrip()}")
56
+
57
+ parts.append(f"<!-- task: {task_path.name} -->\n{task_path.read_text(encoding='utf-8').rstrip()}")
58
+ return "\n\n".join(parts) + "\n"
codeforerunner/check.py CHANGED
@@ -1,9 +1,9 @@
1
- """Drift detection for docs that claim files don't exist when they do."""
1
+ """Drift detection for docs vs repo state."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import fnmatch
5
5
  import re
6
- from dataclasses import dataclass
6
+ from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
8
 
9
9
  from codeforerunner.config import CheckConfig
@@ -17,8 +17,18 @@ class Violation:
17
17
  message: str
18
18
 
19
19
 
20
- _RULES = [
21
- (
20
+ @dataclass(frozen=True)
21
+ class _Rule:
22
+ id: str
23
+ pattern: re.Pattern
24
+ triggers: tuple[str, ...]
25
+ message: str
26
+ invert: bool = False # True = fire when triggers ABSENT (doc claims feature exists but file gone)
27
+
28
+
29
+ _RULES: list[_Rule] = [
30
+ # Normal rules: fire when trigger EXISTS and phrase matches (doc denies a thing that's present)
31
+ _Rule(
22
32
  "R1-no-cli",
23
33
  re.compile(
24
34
  r"(?i)no\s+CLI\s+exists"
@@ -29,56 +39,87 @@ _RULES = [
29
39
  ("src/codeforerunner/cli.py",),
30
40
  "doc claims no CLI exists, but src/codeforerunner/cli.py is present",
31
41
  ),
32
- (
42
+ _Rule(
33
43
  "R2-no-pre-commit",
34
44
  re.compile(r"(?i)no\s+pre[- ]commit(\s+hook)?"),
35
45
  (".pre-commit-hooks.yaml",),
36
46
  "doc claims no pre-commit hook, but .pre-commit-hooks.yaml is present",
37
47
  ),
38
- (
48
+ _Rule(
39
49
  "R3-no-ci",
40
50
  re.compile(r"(?i)no\s+CI(\s+workflow)?"),
41
51
  (".github/workflows/*.yml",),
42
52
  "doc claims no CI workflow, but .github/workflows/*.yml is present",
43
53
  ),
44
- (
54
+ _Rule(
45
55
  "R4-no-installer",
46
56
  re.compile(r"(?i)no\s+installer"),
47
57
  ("src/codeforerunner/installer.py",),
48
58
  "doc claims no installer, but src/codeforerunner/installer.py is present",
49
59
  ),
50
- (
60
+ _Rule(
51
61
  "R5-no-python-package",
52
62
  re.compile(r"(?i)no\s+Python\s+package"),
53
63
  ("pyproject.toml",),
54
64
  "doc claims no Python package, but pyproject.toml is present",
55
65
  ),
56
- (
66
+ _Rule(
57
67
  "R6-no-docker",
58
68
  re.compile(r"(?i)no\s+Docker(\s+image)?|no\s+Dockerfile"),
59
69
  ("Dockerfile", "compose.yml", "docker-compose.yml"),
60
70
  "doc claims no Docker, but Dockerfile/compose file is present",
61
71
  ),
62
- (
72
+ _Rule(
63
73
  "R6b-no-makefile",
64
74
  re.compile(r"(?i)no\s+Makefile"),
65
75
  ("Makefile",),
66
76
  "doc claims no Makefile, but Makefile is present",
67
77
  ),
68
- (
78
+ _Rule(
69
79
  "R7-no-mcp",
70
80
  re.compile(r"(?i)no\s+MCP(\s+server)?"),
71
81
  ("src/codeforerunner/mcp_server.py",),
72
82
  "doc claims no MCP server, but src/codeforerunner/mcp_server.py is present",
73
83
  ),
74
- (
84
+ _Rule(
75
85
  "R8-no-marketplace",
76
86
  re.compile(r"(?i)no\s+marketplace(\s+manifest)?"),
77
87
  ("plugins/codex/marketplace.json",),
78
88
  "doc claims no marketplace, but plugins/codex/marketplace.json is present",
79
89
  ),
90
+ # Inverse rules: fire when trigger ABSENT and phrase matches (doc claims thing exists but file gone)
91
+ _Rule(
92
+ "RI1-missing-cli",
93
+ re.compile(
94
+ r"(?i)\bforerunner\s+(?:init|scan|doc|check|generate|doctor)\b"
95
+ ),
96
+ ("src/codeforerunner/cli.py",),
97
+ "doc references forerunner CLI commands, but src/codeforerunner/cli.py is absent",
98
+ invert=True,
99
+ ),
100
+ _Rule(
101
+ "RI5-missing-python-package",
102
+ re.compile(r"(?i)\bpipx?\s+install\s+codeforerunner\b"),
103
+ ("pyproject.toml",),
104
+ "doc claims package is installable via pip/pipx, but pyproject.toml is absent",
105
+ invert=True,
106
+ ),
107
+ _Rule(
108
+ "RI7-missing-mcp",
109
+ re.compile(r"(?i)\bforerunner\s+mcp-server\b"),
110
+ ("src/codeforerunner/mcp_server.py",),
111
+ "doc references forerunner mcp-server, but src/codeforerunner/mcp_server.py is absent",
112
+ invert=True,
113
+ ),
80
114
  ]
81
115
 
116
+ _VERSION_PIN_RE = re.compile(
117
+ r"(?:codeforerunner==|codeforerunner@v)"
118
+ r"(\d+\.\d+\.\d+)"
119
+ )
120
+ _PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', re.MULTILINE)
121
+ _CHANGELOG_FILENAME = "CHANGELOG.md"
122
+
82
123
 
83
124
  def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
84
125
  for pat in patterns:
@@ -114,6 +155,54 @@ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bo
114
155
  return any(fnmatch.fnmatch(rel, pat) for pat in ignore_patterns)
115
156
 
116
157
 
158
+ def _current_version(repo: Path) -> str | None:
159
+ pyproject = repo / "pyproject.toml"
160
+ if not pyproject.is_file():
161
+ return None
162
+ try:
163
+ text = pyproject.read_text(encoding="utf-8")
164
+ except OSError:
165
+ return None
166
+ m = _PYPROJECT_VERSION_RE.search(text)
167
+ return m.group(1) if m else None
168
+
169
+
170
+ def _check_version_drift(
171
+ repo: Path,
172
+ docs: list[Path],
173
+ ignore_patterns: tuple[str, ...],
174
+ enabled: set[str] | None,
175
+ ) -> list[Violation]:
176
+ if enabled is not None and "RV1-version-drift" not in enabled:
177
+ return []
178
+ current = _current_version(repo)
179
+ if current is None:
180
+ return []
181
+ violations: list[Violation] = []
182
+ for doc in docs:
183
+ if doc.name == _CHANGELOG_FILENAME:
184
+ continue
185
+ if _path_ignored(repo, doc, ignore_patterns):
186
+ continue
187
+ try:
188
+ text = doc.read_text(encoding="utf-8")
189
+ except (OSError, UnicodeDecodeError):
190
+ continue
191
+ for lineno, line in enumerate(text.splitlines(), start=1):
192
+ for m in _VERSION_PIN_RE.finditer(line):
193
+ pinned = m.group(1)
194
+ if pinned != current:
195
+ violations.append(
196
+ Violation(
197
+ path=doc,
198
+ line=lineno,
199
+ rule_id="RV1-version-drift",
200
+ message=f"version pin {pinned!r} does not match current {current!r}",
201
+ )
202
+ )
203
+ return violations
204
+
205
+
117
206
  def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
118
207
  """Scan repo docs for drift; return list of violations.
119
208
 
@@ -124,16 +213,18 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
124
213
  enabled = set(config.enabled_rules) if (config and config.enabled_rules is not None) else None
125
214
  ignore_patterns = config.ignore_paths if config else ()
126
215
 
127
- active_rules = [
128
- (rid, rx, msg)
129
- for rid, rx, triggers, msg in _RULES
130
- if _trigger_exists(repo, triggers) and (enabled is None or rid in enabled)
131
- ]
132
- if not active_rules:
133
- return []
216
+ docs = _scanned_docs(repo)
217
+
218
+ active_rules: list[_Rule] = []
219
+ for rule in _RULES:
220
+ if enabled is not None and rule.id not in enabled:
221
+ continue
222
+ trigger_found = _trigger_exists(repo, rule.triggers)
223
+ if (not rule.invert and trigger_found) or (rule.invert and not trigger_found):
224
+ active_rules.append(rule)
134
225
 
135
226
  violations: list[Violation] = []
136
- for doc in _scanned_docs(repo):
227
+ for doc in docs:
137
228
  if _path_ignored(repo, doc, ignore_patterns):
138
229
  continue
139
230
  try:
@@ -141,11 +232,13 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
141
232
  except (OSError, UnicodeDecodeError):
142
233
  continue
143
234
  for lineno, line in enumerate(text.splitlines(), start=1):
144
- for rid, rx, msg in active_rules:
145
- if rx.search(line):
235
+ for rule in active_rules:
236
+ if rule.pattern.search(line):
146
237
  violations.append(
147
- Violation(path=doc, line=lineno, rule_id=rid, message=msg)
238
+ Violation(path=doc, line=lineno, rule_id=rule.id, message=rule.message)
148
239
  )
240
+
241
+ violations.extend(_check_version_drift(repo, docs, ignore_patterns, enabled))
149
242
  return violations
150
243
 
151
244
 
codeforerunner/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- """Thin CLI orchestration. Product logic lives in `prompts/`. See SPEC.md §D.cli."""
1
+ """Thin CLI orchestration. Product logic lives in prompts/. See SPEC.md §D.cli."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,36 +8,29 @@ 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
12
+
11
13
  SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
12
14
  SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
13
15
 
14
16
 
15
- def _repo_root(start: Path | None = None) -> Path:
16
- """Walk up from cwd (or `start`) to a directory containing `prompts/tasks`."""
17
- here = (start or Path.cwd()).resolve()
18
- for candidate in [here, *here.parents]:
19
- if (candidate / "prompts" / "tasks").is_dir():
20
- return candidate
21
- raise FileNotFoundError(
22
- "could not locate codeforerunner repo root (no prompts/tasks/ found upward)"
23
- )
24
-
25
-
26
- def _read(path: Path) -> str:
27
- return path.read_text(encoding="utf-8")
28
-
29
-
30
17
  def cmd_doc(args: argparse.Namespace) -> int:
31
- """Resolve `prompts/system/base.md` + `prompts/partials/*.md` + `prompts/tasks/<task>.md` to stdout."""
32
- root = _repo_root(Path(args.repo) if args.repo else None)
33
- task_path = root / "prompts" / "tasks" / f"{args.task}.md"
18
+ """Resolve base + partials + task bundle to stdout."""
19
+ try:
20
+ prompts_root = find_prompts_root(args.repo)
21
+ except FileNotFoundError as e:
22
+ print(f"error: {e}", file=sys.stderr)
23
+ return 2
24
+
25
+ task_path = prompts_root / "tasks" / f"{args.task}.md"
34
26
  if not task_path.is_file():
35
27
  print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
36
28
  return 2
37
29
 
30
+ repo_root = Path(args.repo) if args.repo else Path.cwd()
38
31
  if (
39
32
  args.task not in SCAN_EXEMPT_TASKS
40
- and (root / "forerunner.config.yaml").is_file()
33
+ and (repo_root / "forerunner.config.yaml").is_file()
41
34
  and not os.environ.get(SCAN_DONE_ENV)
42
35
  ):
43
36
  print(
@@ -46,18 +39,11 @@ def cmd_doc(args: argparse.Namespace) -> int:
46
39
  file=sys.stderr,
47
40
  )
48
41
 
49
- parts: list[str] = []
50
- base = root / "prompts" / "system" / "base.md"
51
- if base.is_file():
52
- parts.append(f"<!-- system: base.md -->\n{_read(base).rstrip()}")
53
-
54
- partials_dir = root / "prompts" / "partials"
55
- if partials_dir.is_dir():
56
- for p in sorted(partials_dir.glob("*.md")):
57
- parts.append(f"<!-- partial: {p.name} -->\n{_read(p).rstrip()}")
58
-
59
- parts.append(f"<!-- task: {task_path.name} -->\n{_read(task_path).rstrip()}")
60
- sys.stdout.write("\n\n".join(parts) + "\n")
42
+ try:
43
+ sys.stdout.write(resolve_bundle(prompts_root, args.task))
44
+ except FileNotFoundError as e:
45
+ print(f"error: {e}", file=sys.stderr)
46
+ return 2
61
47
  return 0
62
48
 
63
49
 
@@ -89,11 +75,8 @@ def cmd_scan(args: argparse.Namespace) -> int:
89
75
 
90
76
 
91
77
  def cmd_check(args: argparse.Namespace) -> int:
92
- """Run check rules when `forerunner.config.yaml` present. Silent no-op otherwise."""
93
- try:
94
- root = _repo_root(Path(args.repo) if args.repo else None)
95
- except FileNotFoundError:
96
- root = Path.cwd()
78
+ """Run check rules when forerunner.config.yaml present. Silent no-op otherwise."""
79
+ root = Path(args.repo).resolve() if args.repo else Path.cwd()
97
80
  from codeforerunner import check as _check
98
81
  from codeforerunner.config import ConfigError, load_from_repo
99
82
  try:
@@ -112,8 +95,12 @@ def cmd_check(args: argparse.Namespace) -> int:
112
95
 
113
96
  def cmd_mcp_server(args: argparse.Namespace) -> int:
114
97
  from codeforerunner import mcp_server
115
- root = _repo_root(Path(args.repo) if args.repo else None)
116
- return mcp_server.serve(root)
98
+ try:
99
+ prompts_root = find_prompts_root(args.repo)
100
+ except FileNotFoundError as e:
101
+ print(f"mcp_server: {e}", file=sys.stderr)
102
+ return 2
103
+ return mcp_server.serve(prompts_root)
117
104
 
118
105
 
119
106
  def cmd_generate(args: argparse.Namespace) -> int:
@@ -121,8 +108,8 @@ def cmd_generate(args: argparse.Namespace) -> int:
121
108
  from codeforerunner import providers as _providers
122
109
  from codeforerunner.config import load_from_repo
123
110
 
124
- root = _repo_root(Path(args.repo) if args.repo else None)
125
- cfg = load_from_repo(root)
111
+ repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
112
+ cfg = load_from_repo(repo_root)
126
113
 
127
114
  provider_name = args.provider or (cfg.provider if cfg else "anthropic")
128
115
  model = args.model or (cfg.model if cfg else None)
@@ -133,7 +120,6 @@ def cmd_generate(args: argparse.Namespace) -> int:
133
120
  import io as _io
134
121
  buf = _io.StringIO()
135
122
  ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
136
- # Temporarily redirect stdout to capture cmd_doc output.
137
123
  real_stdout = sys.stdout
138
124
  sys.stdout = buf
139
125
  try:
@@ -146,12 +132,20 @@ def cmd_generate(args: argparse.Namespace) -> int:
146
132
  env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
147
133
  api_key = os.environ.get(env_var)
148
134
  if api_key is None and provider_name != "ollama":
149
- print(
150
- f"error: missing API key; set ${env_var}",
151
- file=sys.stderr,
152
- )
135
+ print(f"error: missing API key; set ${env_var}", file=sys.stderr)
153
136
  return 3
154
137
 
138
+ if getattr(args, "stream", False):
139
+ try:
140
+ for chunk in provider.stream(prompt=buf.getvalue(), model=model, api_key=api_key):
141
+ sys.stdout.write(chunk)
142
+ sys.stdout.flush()
143
+ except _providers.ProviderError as e:
144
+ print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
145
+ return 4
146
+ sys.stdout.write("\n")
147
+ return 0
148
+
155
149
  try:
156
150
  result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
157
151
  except _providers.ProviderError as e:
@@ -168,7 +162,15 @@ def cmd_generate(args: argparse.Namespace) -> int:
168
162
 
169
163
  def cmd_doctor(args: argparse.Namespace) -> int:
170
164
  from codeforerunner import doctor
171
- root = _repo_root(Path(args.repo) if args.repo else None)
165
+ from codeforerunner.config import CONFIG_FILENAME
166
+ root = Path(args.repo).resolve() if args.repo else Path.cwd()
167
+ if getattr(args, "fix", False):
168
+ cfg_path = root / CONFIG_FILENAME
169
+ if not cfg_path.is_file():
170
+ cfg_path.write_text(doctor.starter_config(), encoding="utf-8")
171
+ print(f"wrote {cfg_path}", file=sys.stderr)
172
+ else:
173
+ print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
172
174
  findings = doctor.run(root)
173
175
  sys.stdout.write(doctor.format_report(findings) + "\n")
174
176
  return 1 if any(f.severity == "error" for f in findings) else 0
@@ -179,7 +181,7 @@ def build_parser() -> argparse.ArgumentParser:
179
181
  prog="forerunner",
180
182
  description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
181
183
  )
182
- p.add_argument("--repo", help="path to repo root (defaults to cwd ancestor with prompts/tasks/)")
184
+ p.add_argument("--repo", default=argparse.SUPPRESS, help="path to repo root")
183
185
  from codeforerunner import __version__ as _version
184
186
  p.add_argument("--version", action="version", version=f"forerunner {_version}")
185
187
  sub = p.add_subparsers(dest="cmd", required=True, metavar="<cmd>")
@@ -209,15 +211,26 @@ def build_parser() -> argparse.ArgumentParser:
209
211
  s_check.set_defaults(func=cmd_check)
210
212
 
211
213
  s_mcp = sub.add_parser("mcp-server", help="serve prompt bundles as MCP tools over stdio")
214
+ s_mcp.add_argument(
215
+ "--repo",
216
+ default=argparse.SUPPRESS,
217
+ help="path containing prompts/tasks/ (default: package-bundled prompts)",
218
+ )
212
219
  s_mcp.set_defaults(func=cmd_mcp_server)
213
220
 
214
221
  s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
222
+ s_doctor.add_argument(
223
+ "--fix",
224
+ action="store_true",
225
+ help="write a starter forerunner.config.yaml if absent",
226
+ )
215
227
  s_doctor.set_defaults(func=cmd_doctor)
216
228
 
217
229
  s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
218
230
  s_gen.add_argument("task", help="task basename under prompts/tasks/")
219
231
  s_gen.add_argument("--provider", help="override config provider")
220
232
  s_gen.add_argument("--model", help="override config model")
233
+ s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
221
234
  s_gen.set_defaults(func=cmd_generate)
222
235
 
223
236
  from codeforerunner import installer
@@ -229,6 +242,8 @@ def build_parser() -> argparse.ArgumentParser:
229
242
  def main(argv: Sequence[str] | None = None) -> int:
230
243
  parser = build_parser()
231
244
  args = parser.parse_args(argv)
245
+ if not hasattr(args, "repo"):
246
+ args.repo = None
232
247
  return args.func(args)
233
248
 
234
249
 
codeforerunner/doctor.py CHANGED
@@ -274,6 +274,26 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
274
274
  ]
275
275
 
276
276
 
277
+ _STARTER_CONFIG = """\
278
+ # forerunner.config.yaml — generated by `forerunner doctor --fix`
279
+ # See https://github.com/derek-palmer/codeforerunner for docs.
280
+
281
+ enabled_rules:
282
+ - R1-no-cli
283
+ - R2-no-pre-commit
284
+ - R3-no-ci
285
+ - R4-no-installer
286
+ - R5-no-python-package
287
+ - R7-no-mcp
288
+ - R8-no-marketplace
289
+ ignore_paths: []
290
+ """
291
+
292
+
293
+ def starter_config() -> str:
294
+ return _STARTER_CONFIG
295
+
296
+
277
297
  def run(repo: Path) -> list[Finding]:
278
298
  repo = repo.resolve()
279
299
  findings: list[Finding] = []
@@ -291,9 +291,7 @@ def add_subparser(sub: argparse._SubParsersAction) -> None:
291
291
 
292
292
 
293
293
  def _cli_entry(args: argparse.Namespace) -> int:
294
- from codeforerunner.cli import _repo_root # local import to avoid cycle
295
-
296
- root = _repo_root(Path(args.repo) if args.repo else None)
294
+ root = Path(args.repo).resolve() if args.repo else Path.cwd()
297
295
  return install(
298
296
  agent=args.agent,
299
297
  repo_root=root,