Skip to content

Commit ba4cdc3

Browse files
derek-palmerclaude
andcommitted
fix(mcp): bundle prompts in package, fix versions, dedup, protocol update, --repo on subcommand
Prompts bundled in package (pip-install now works without --repo): - Move prompts/ → src/codeforerunner/prompts/ so setuptools includes them - pyproject.toml package-data adds prompts/**/*.md - New bundle.py: find_prompts_root() (cwd-walk → package fallback) + resolve_bundle(); single source of truth for both cli.py and mcp_server.py Version fixes: - __init__.py: importlib.metadata.version() instead of hardcoded "0.2.0" - mcp_server.py SERVER_VERSION reads __version__ (was "0.2.0", now "0.3.0") - forerunner --version now prints 0.3.0; MCP serverInfo matches MCP protocol version: 2024-11-05 → 2025-03-26 Deduplication: - Remove _repo_root() from cli.py and mcp_server.py; both import bundle.py - installer.py: drop _repo_root import from cli.py (used Path(args.repo) directly) mcp-server subcommand now has explicit --repo flag: - forerunner mcp-server --repo /path works and shows in --help - Uses argparse.SUPPRESS default so global --repo is not overridden Tests: update 9 tests for moved prompts path and new protocol version Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 17fe2e8 commit ba4cdc3

22 files changed

Lines changed: 141 additions & 119 deletions

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ forerunner = "codeforerunner.cli:main"
2626
where = ["src"]
2727

2828
[tool.setuptools.package-data]
29-
codeforerunner = ["py.typed"]
29+
codeforerunner = ["py.typed", "prompts/**/*.md"]
3030

3131
[tool.pytest.ini_options]
3232
testpaths = ["tests"]

‎src/codeforerunner/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

‎src/codeforerunner/bundle.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"

‎src/codeforerunner/cli.py‎

Lines changed: 38 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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."""
22

33
from __future__ import annotations
44

@@ -8,36 +8,29 @@
88
from pathlib import Path
99
from typing import Sequence
1010

11+
from codeforerunner.bundle import find_prompts_root, resolve_bundle
12+
1113
SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
1214
SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
1315

1416

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-
3017
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"
3426
if not task_path.is_file():
3527
print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
3628
return 2
3729

30+
repo_root = Path(args.repo) if args.repo else Path.cwd()
3831
if (
3932
args.task not in SCAN_EXEMPT_TASKS
40-
and (root / "forerunner.config.yaml").is_file()
33+
and (repo_root / "forerunner.config.yaml").is_file()
4134
and not os.environ.get(SCAN_DONE_ENV)
4235
):
4336
print(
@@ -46,18 +39,11 @@ def cmd_doc(args: argparse.Namespace) -> int:
4639
file=sys.stderr,
4740
)
4841

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
6147
return 0
6248

6349

@@ -89,11 +75,8 @@ def cmd_scan(args: argparse.Namespace) -> int:
8975

9076

9177
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()
9780
from codeforerunner import check as _check
9881
from codeforerunner.config import ConfigError, load_from_repo
9982
try:
@@ -112,17 +95,21 @@ def cmd_check(args: argparse.Namespace) -> int:
11295

11396
def cmd_mcp_server(args: argparse.Namespace) -> int:
11497
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)
117104

118105

119106
def cmd_generate(args: argparse.Namespace) -> int:
120107
"""Resolve the bundle for <task> and send it to the configured provider."""
121108
from codeforerunner import providers as _providers
122109
from codeforerunner.config import load_from_repo
123110

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)
126113

127114
provider_name = args.provider or (cfg.provider if cfg else "anthropic")
128115
model = args.model or (cfg.model if cfg else None)
@@ -133,7 +120,6 @@ def cmd_generate(args: argparse.Namespace) -> int:
133120
import io as _io
134121
buf = _io.StringIO()
135122
ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
136-
# Temporarily redirect stdout to capture cmd_doc output.
137123
real_stdout = sys.stdout
138124
sys.stdout = buf
139125
try:
@@ -146,10 +132,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
146132
env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
147133
api_key = os.environ.get(env_var)
148134
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)
153136
return 3
154137

155138
try:
@@ -168,7 +151,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
168151

169152
def cmd_doctor(args: argparse.Namespace) -> int:
170153
from codeforerunner import doctor
171-
root = _repo_root(Path(args.repo) if args.repo else None)
154+
root = Path(args.repo).resolve() if args.repo else Path.cwd()
172155
findings = doctor.run(root)
173156
sys.stdout.write(doctor.format_report(findings) + "\n")
174157
return 1 if any(f.severity == "error" for f in findings) else 0
@@ -179,7 +162,7 @@ def build_parser() -> argparse.ArgumentParser:
179162
prog="forerunner",
180163
description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
181164
)
182-
p.add_argument("--repo", help="path to repo root (defaults to cwd ancestor with prompts/tasks/)")
165+
p.add_argument("--repo", default=argparse.SUPPRESS, help="path to repo root")
183166
from codeforerunner import __version__ as _version
184167
p.add_argument("--version", action="version", version=f"forerunner {_version}")
185168
sub = p.add_subparsers(dest="cmd", required=True, metavar="<cmd>")
@@ -209,6 +192,11 @@ def build_parser() -> argparse.ArgumentParser:
209192
s_check.set_defaults(func=cmd_check)
210193

211194
s_mcp = sub.add_parser("mcp-server", help="serve prompt bundles as MCP tools over stdio")
195+
s_mcp.add_argument(
196+
"--repo",
197+
default=argparse.SUPPRESS,
198+
help="path containing prompts/tasks/ (default: package-bundled prompts)",
199+
)
212200
s_mcp.set_defaults(func=cmd_mcp_server)
213201

214202
s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
@@ -229,6 +217,8 @@ def build_parser() -> argparse.ArgumentParser:
229217
def main(argv: Sequence[str] | None = None) -> int:
230218
parser = build_parser()
231219
args = parser.parse_args(argv)
220+
if not hasattr(args, "repo"):
221+
args.repo = None
232222
return args.func(args)
233223

234224

‎src/codeforerunner/installer.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,7 @@ def add_subparser(sub: argparse._SubParsersAction) -> None:
291291

292292

293293
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()
297295
return install(
298296
agent=args.agent,
299297
repo_root=root,

0 commit comments

Comments
 (0)