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
33from __future__ import annotations
44
88from pathlib import Path
99from typing import Sequence
1010
11+ from codeforerunner .bundle import find_prompts_root , resolve_bundle
12+
1113SCAN_EXEMPT_TASKS = frozenset ({"scan" , "init-agent-onboarding" })
1214SCAN_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-
3017def 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
9177def 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
11396def 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
119106def 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
169152def 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:
229217def 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
0 commit comments