codeforerunner 0.3.0__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.
- codeforerunner/__init__.py +1 -0
- codeforerunner/check.py +156 -0
- codeforerunner/cli.py +236 -0
- codeforerunner/config.py +176 -0
- codeforerunner/doctor.py +321 -0
- codeforerunner/installer.py +304 -0
- codeforerunner/mcp_server.py +177 -0
- codeforerunner/providers/__init__.py +36 -0
- codeforerunner/providers/anthropic.py +61 -0
- codeforerunner/providers/base.py +31 -0
- codeforerunner/providers/google.py +62 -0
- codeforerunner/providers/ollama.py +56 -0
- codeforerunner/providers/openai.py +59 -0
- codeforerunner-0.3.0.dist-info/METADATA +120 -0
- codeforerunner-0.3.0.dist-info/RECORD +19 -0
- codeforerunner-0.3.0.dist-info/WHEEL +5 -0
- codeforerunner-0.3.0.dist-info/entry_points.txt +2 -0
- codeforerunner-0.3.0.dist-info/licenses/LICENSE.md +71 -0
- codeforerunner-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Minimal stdio MCP server exposing prompt bundles as tools. See SPEC.md §D.mcp.
|
|
2
|
+
|
|
3
|
+
Hand-rolled JSON-RPC 2.0 over line-delimited stdio. Stdlib only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Iterable
|
|
12
|
+
|
|
13
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
14
|
+
SERVER_NAME = "codeforerunner"
|
|
15
|
+
SERVER_VERSION = "0.2.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _repo_root(start: Path | None = None) -> Path:
|
|
19
|
+
here = (start or Path.cwd()).resolve()
|
|
20
|
+
for candidate in [here, *here.parents]:
|
|
21
|
+
if (candidate / "prompts" / "tasks").is_dir():
|
|
22
|
+
return candidate
|
|
23
|
+
raise FileNotFoundError(
|
|
24
|
+
"could not locate codeforerunner repo root (no prompts/tasks/ found upward)"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read(path: Path) -> str:
|
|
29
|
+
return path.read_text(encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_bundle(repo: Path, task: str) -> str:
|
|
33
|
+
"""Concatenate system/base.md + sorted partials/*.md + tasks/<task>.md with marker comments."""
|
|
34
|
+
task_path = repo / "prompts" / "tasks" / f"{task}.md"
|
|
35
|
+
if not task_path.is_file():
|
|
36
|
+
raise FileNotFoundError(f"unknown task '{task}' (no {task_path})")
|
|
37
|
+
|
|
38
|
+
parts: list[str] = []
|
|
39
|
+
base = repo / "prompts" / "system" / "base.md"
|
|
40
|
+
if base.is_file():
|
|
41
|
+
parts.append(f"<!-- system: base.md -->\n{_read(base).rstrip()}")
|
|
42
|
+
|
|
43
|
+
partials_dir = repo / "prompts" / "partials"
|
|
44
|
+
if partials_dir.is_dir():
|
|
45
|
+
for p in sorted(partials_dir.glob("*.md")):
|
|
46
|
+
parts.append(f"<!-- partial: {p.name} -->\n{_read(p).rstrip()}")
|
|
47
|
+
|
|
48
|
+
parts.append(f"<!-- task: {task_path.name} -->\n{_read(task_path).rstrip()}")
|
|
49
|
+
return "\n\n".join(parts) + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _list_tasks(repo: Path) -> list[Path]:
|
|
53
|
+
tasks_dir = repo / "prompts" / "tasks"
|
|
54
|
+
if not tasks_dir.is_dir():
|
|
55
|
+
return []
|
|
56
|
+
return sorted(tasks_dir.glob("*.md"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _description_for(task_path: Path) -> str:
|
|
60
|
+
"""First non-empty markdown line, stripped of leading '#' chars and whitespace."""
|
|
61
|
+
for raw in task_path.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
line = raw.strip()
|
|
63
|
+
if not line:
|
|
64
|
+
continue
|
|
65
|
+
return line.lstrip("#").strip()
|
|
66
|
+
return task_path.stem
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tools(repo: Path) -> list[dict[str, Any]]:
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
"name": p.stem,
|
|
73
|
+
"description": _description_for(p),
|
|
74
|
+
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
|
75
|
+
}
|
|
76
|
+
for p in _list_tasks(repo)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ok(req_id: Any, result: Any) -> dict[str, Any]:
|
|
81
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _err(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
85
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
SCAN_EXEMPT_TOOLS = frozenset({"init-agent-onboarding", "scan"})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle(repo: Path, msg: dict[str, Any], state: dict[str, Any]) -> dict[str, Any] | None:
|
|
92
|
+
method = msg.get("method")
|
|
93
|
+
req_id = msg.get("id")
|
|
94
|
+
params = msg.get("params") or {}
|
|
95
|
+
|
|
96
|
+
# Notifications: no id, no response.
|
|
97
|
+
if method == "notifications/initialized":
|
|
98
|
+
return None
|
|
99
|
+
if req_id is None and isinstance(method, str) and method.startswith("notifications/"):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
if method == "initialize":
|
|
103
|
+
return _ok(
|
|
104
|
+
req_id,
|
|
105
|
+
{
|
|
106
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
107
|
+
"capabilities": {"tools": {}},
|
|
108
|
+
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if method == "tools/list":
|
|
113
|
+
return _ok(req_id, {"tools": _tools(repo)})
|
|
114
|
+
|
|
115
|
+
if method == "tools/call":
|
|
116
|
+
name = params.get("name")
|
|
117
|
+
task_path = repo / "prompts" / "tasks" / f"{name}.md"
|
|
118
|
+
if not isinstance(name, str) or not task_path.is_file():
|
|
119
|
+
return _err(req_id, -32602, f"unknown tool: {name!r}")
|
|
120
|
+
if name not in SCAN_EXEMPT_TOOLS and not state.get("scan_called"):
|
|
121
|
+
return _err(
|
|
122
|
+
req_id,
|
|
123
|
+
-32000,
|
|
124
|
+
"scan-first required: call tools/call name=scan before this task (SPEC V2)",
|
|
125
|
+
)
|
|
126
|
+
if name == "scan":
|
|
127
|
+
state["scan_called"] = True
|
|
128
|
+
try:
|
|
129
|
+
text = resolve_bundle(repo, name)
|
|
130
|
+
except Exception as e: # pragma: no cover - defensive
|
|
131
|
+
return _err(req_id, -32603, f"internal error: {e}")
|
|
132
|
+
return _ok(
|
|
133
|
+
req_id,
|
|
134
|
+
{"content": [{"type": "text", "text": text}], "isError": False},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return _err(req_id, -32601, f"method not found: {method!r}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def serve(repo: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdout, stderr=sys.stderr) -> int:
|
|
141
|
+
state: dict[str, Any] = {"scan_called": False}
|
|
142
|
+
for raw in stdin:
|
|
143
|
+
line = raw.strip()
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
msg = json.loads(line)
|
|
148
|
+
except json.JSONDecodeError as e:
|
|
149
|
+
print(f"mcp_server: invalid JSON: {e}", file=stderr)
|
|
150
|
+
resp = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
|
|
151
|
+
stdout.write(json.dumps(resp) + "\n")
|
|
152
|
+
stdout.flush()
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
resp = _handle(repo, msg, state)
|
|
157
|
+
except Exception as e: # pragma: no cover - defensive
|
|
158
|
+
print(f"mcp_server: handler error: {e}", file=stderr)
|
|
159
|
+
resp = _err(msg.get("id"), -32603, f"internal error: {e}")
|
|
160
|
+
|
|
161
|
+
if resp is not None:
|
|
162
|
+
stdout.write(json.dumps(resp) + "\n")
|
|
163
|
+
stdout.flush()
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: list[str] | None = None) -> int:
|
|
168
|
+
try:
|
|
169
|
+
repo = _repo_root()
|
|
170
|
+
except FileNotFoundError as e:
|
|
171
|
+
print(f"mcp_server: {e}", file=sys.stderr)
|
|
172
|
+
return 2
|
|
173
|
+
return serve(repo)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Provider registry. See SPEC.md §T38."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codeforerunner.providers.anthropic import AnthropicProvider
|
|
6
|
+
from codeforerunner.providers.base import CompletionResult, Provider, ProviderError
|
|
7
|
+
from codeforerunner.providers.google import GoogleProvider
|
|
8
|
+
from codeforerunner.providers.ollama import OllamaProvider
|
|
9
|
+
from codeforerunner.providers.openai import OpenAIProvider
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AnthropicProvider",
|
|
13
|
+
"CompletionResult",
|
|
14
|
+
"GoogleProvider",
|
|
15
|
+
"OllamaProvider",
|
|
16
|
+
"OpenAIProvider",
|
|
17
|
+
"Provider",
|
|
18
|
+
"ProviderError",
|
|
19
|
+
"REGISTRY",
|
|
20
|
+
"get",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
REGISTRY: dict[str, type] = {
|
|
24
|
+
"anthropic": AnthropicProvider,
|
|
25
|
+
"openai": OpenAIProvider,
|
|
26
|
+
"google": GoogleProvider,
|
|
27
|
+
"ollama": OllamaProvider,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get(name: str) -> type:
|
|
32
|
+
if name not in REGISTRY:
|
|
33
|
+
raise ProviderError(
|
|
34
|
+
f"unknown provider '{name}' (expected one of {sorted(REGISTRY)})"
|
|
35
|
+
)
|
|
36
|
+
return REGISTRY[name]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Anthropic Messages API provider. Stdlib HTTP only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnthropicProvider:
|
|
13
|
+
name = "anthropic"
|
|
14
|
+
default_env_var = "ANTHROPIC_API_KEY"
|
|
15
|
+
default_model = "claude-opus-4-5"
|
|
16
|
+
|
|
17
|
+
endpoint = "https://api.anthropic.com/v1/messages"
|
|
18
|
+
|
|
19
|
+
def complete(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
prompt: str,
|
|
23
|
+
model: str | None = None,
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
) -> CompletionResult:
|
|
26
|
+
if not api_key:
|
|
27
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
28
|
+
model = model or self.default_model
|
|
29
|
+
body = json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"model": model,
|
|
32
|
+
"max_tokens": 4096,
|
|
33
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
34
|
+
}
|
|
35
|
+
).encode("utf-8")
|
|
36
|
+
req = urllib.request.Request(
|
|
37
|
+
self.endpoint,
|
|
38
|
+
data=body,
|
|
39
|
+
method="POST",
|
|
40
|
+
headers={
|
|
41
|
+
"x-api-key": api_key,
|
|
42
|
+
"anthropic-version": "2023-06-01",
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req) as resp:
|
|
48
|
+
raw = resp.read()
|
|
49
|
+
except urllib.error.HTTPError as e:
|
|
50
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
51
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
52
|
+
except urllib.error.URLError as e:
|
|
53
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(raw.decode("utf-8"))
|
|
56
|
+
text = data["content"][0]["text"]
|
|
57
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
|
|
58
|
+
raise ProviderError(f"malformed response: {e}") from e
|
|
59
|
+
return CompletionResult(
|
|
60
|
+
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
61
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Provider protocol + shared types. See SPEC.md §T38."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CompletionResult:
|
|
11
|
+
text: str
|
|
12
|
+
model: str
|
|
13
|
+
usage: dict | None = None # provider-reported token counts; None if unknown
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Provider(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
default_env_var: str # e.g. "ANTHROPIC_API_KEY"
|
|
19
|
+
default_model: str # provider's recommended default
|
|
20
|
+
|
|
21
|
+
def complete(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
prompt: str,
|
|
25
|
+
model: str | None = None,
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
) -> CompletionResult: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProviderError(Exception):
|
|
31
|
+
"""Raised on provider HTTP failures, missing keys, or malformed responses."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Google Gemini generateContent provider. Stdlib HTTP only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoogleProvider:
|
|
14
|
+
name = "google"
|
|
15
|
+
default_env_var = "GOOGLE_API_KEY"
|
|
16
|
+
default_model = "gemini-2.5-pro"
|
|
17
|
+
|
|
18
|
+
endpoint_template = (
|
|
19
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def complete(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
prompt: str,
|
|
26
|
+
model: str | None = None,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
) -> CompletionResult:
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
31
|
+
model = model or self.default_model
|
|
32
|
+
url = self.endpoint_template.format(
|
|
33
|
+
model=urllib.parse.quote(model, safe=""),
|
|
34
|
+
key=urllib.parse.quote(api_key, safe=""),
|
|
35
|
+
)
|
|
36
|
+
body = json.dumps(
|
|
37
|
+
{"contents": [{"parts": [{"text": prompt}]}]}
|
|
38
|
+
).encode("utf-8")
|
|
39
|
+
req = urllib.request.Request(
|
|
40
|
+
url,
|
|
41
|
+
data=body,
|
|
42
|
+
method="POST",
|
|
43
|
+
headers={"content-type": "application/json"},
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req) as resp:
|
|
47
|
+
raw = resp.read()
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
50
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
51
|
+
except urllib.error.URLError as e:
|
|
52
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(raw.decode("utf-8"))
|
|
55
|
+
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
56
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
|
|
57
|
+
raise ProviderError(f"malformed response: {e}") from e
|
|
58
|
+
return CompletionResult(
|
|
59
|
+
text=text,
|
|
60
|
+
model=data.get("modelVersion", model),
|
|
61
|
+
usage=data.get("usageMetadata"),
|
|
62
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Ollama local provider. Stdlib HTTP only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
|
+
|
|
12
|
+
DEFAULT_HOST = "http://localhost:11434"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OllamaProvider:
|
|
16
|
+
name = "ollama"
|
|
17
|
+
default_env_var = "OLLAMA_HOST"
|
|
18
|
+
default_model = "llama3"
|
|
19
|
+
|
|
20
|
+
def complete(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
prompt: str,
|
|
24
|
+
model: str | None = None,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
) -> CompletionResult:
|
|
27
|
+
# api_key is interpreted as a base URL override; fall back to env then default.
|
|
28
|
+
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
29
|
+
base = base.rstrip("/")
|
|
30
|
+
model = model or self.default_model
|
|
31
|
+
url = f"{base}/api/generate"
|
|
32
|
+
body = json.dumps(
|
|
33
|
+
{"model": model, "prompt": prompt, "stream": False}
|
|
34
|
+
).encode("utf-8")
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
url,
|
|
37
|
+
data=body,
|
|
38
|
+
method="POST",
|
|
39
|
+
headers={"content-type": "application/json"},
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(req) as resp:
|
|
43
|
+
raw = resp.read()
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
46
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
47
|
+
except urllib.error.URLError as e:
|
|
48
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(raw.decode("utf-8"))
|
|
51
|
+
text = data["response"]
|
|
52
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
53
|
+
raise ProviderError(f"malformed response: {e}") from e
|
|
54
|
+
usage_keys = ("prompt_eval_count", "eval_count", "total_duration")
|
|
55
|
+
usage = {k: data[k] for k in usage_keys if k in data} or None
|
|
56
|
+
return CompletionResult(text=text, model=data.get("model", model), usage=usage)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""OpenAI chat completions provider. Stdlib HTTP only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAIProvider:
|
|
13
|
+
name = "openai"
|
|
14
|
+
default_env_var = "OPENAI_API_KEY"
|
|
15
|
+
default_model = "gpt-4o"
|
|
16
|
+
|
|
17
|
+
endpoint = "https://api.openai.com/v1/chat/completions"
|
|
18
|
+
|
|
19
|
+
def complete(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
prompt: str,
|
|
23
|
+
model: str | None = None,
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
) -> CompletionResult:
|
|
26
|
+
if not api_key:
|
|
27
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
28
|
+
model = model or self.default_model
|
|
29
|
+
body = json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"model": model,
|
|
32
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
33
|
+
}
|
|
34
|
+
).encode("utf-8")
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
self.endpoint,
|
|
37
|
+
data=body,
|
|
38
|
+
method="POST",
|
|
39
|
+
headers={
|
|
40
|
+
"Authorization": f"Bearer {api_key}",
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req) as resp:
|
|
46
|
+
raw = resp.read()
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
49
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
50
|
+
except urllib.error.URLError as e:
|
|
51
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(raw.decode("utf-8"))
|
|
54
|
+
text = data["choices"][0]["message"]["content"]
|
|
55
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
|
|
56
|
+
raise ProviderError(f"malformed response: {e}") from e
|
|
57
|
+
return CompletionResult(
|
|
58
|
+
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
59
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codeforerunner
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Model-agnostic repository documentation tooling (prompt-first; thin CLI).
|
|
5
|
+
Author: Derek Palmer
|
|
6
|
+
License-Expression: LicenseRef-Codeforerunner-SAL-0.1
|
|
7
|
+
Project-URL: Repository, https://github.com/derek-palmer/codeforerunner
|
|
8
|
+
Project-URL: Issues, https://github.com/derek-palmer/codeforerunner/issues
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: PyYAML>=6.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
# codeForerunner
|
|
18
|
+
|
|
19
|
+
CodeForerunner is a model-agnostic documentation agent that acts as overwatch for your repository, automatically analyzing code and maintaining docs, diagrams, and architecture knowledge as your codebase evolves over time.
|
|
20
|
+
|
|
21
|
+
The current repo is the prompt-first foundation for that agent: it ships prompt assets for understanding a codebase and generating developer docs. A thin Python CLI (including `forerunner mcp-server` and a scoped `forerunner init --full / --agents-only`), an idempotent skill installer, pre-commit + CI hooks, and a PyPI publish workflow now wrap those prompts; the first published PyPI release remains pending.
|
|
22
|
+
|
|
23
|
+
## Current State
|
|
24
|
+
|
|
25
|
+
- Core product: Markdown prompts in `prompts/`.
|
|
26
|
+
- Agent package artifacts: Codex plugin files under `plugins/codeforerunner/` and Claude Code plugin files under `.claude-plugin/` plus `skills/codeforerunner/`.
|
|
27
|
+
- Python package: `pyproject.toml` + `src/codeforerunner/` expose a `forerunner` console script. `forerunner doc <task>` resolves the prompt bundle (base + partials + task) to stdout; `forerunner install <agent>` idempotently writes the canonical skill into agent-specific directories; `forerunner init` resolves the agent-onboarding bundle (with `--full` to prepend a scan or `--agents-only` for the default scope); `forerunner scan` resolves the scan bundle; `forerunner mcp-server` serves prompt bundles as MCP tools over stdio.
|
|
28
|
+
- Hooks: `.pre-commit-hooks.yaml` exposes a `forerunner-check` hook; `.github/workflows/forerunner-check.yml` mirrors it in CI. Both no-op when `forerunner.config.yaml` is absent.
|
|
29
|
+
- Current config: `forerunner.config.yaml.example` documents the schema now parsed by `src/codeforerunner/config.py`; see "Configuration" below.
|
|
30
|
+
- Not currently present: Docker image, Makefile, published PyPI release.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
After the first PyPI release:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pipx install codeforerunner # recommended; isolated environment
|
|
38
|
+
pip install codeforerunner # alternative
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/derek-palmer/codeForerunner
|
|
45
|
+
cd codeForerunner
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then `forerunner --help` should print the subcommand list.
|
|
50
|
+
|
|
51
|
+
## Prompt Layout
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
prompts/
|
|
55
|
+
├── system/
|
|
56
|
+
│ └── base.md
|
|
57
|
+
├── partials/
|
|
58
|
+
│ ├── context-format.md
|
|
59
|
+
│ ├── output-rules.md
|
|
60
|
+
│ └── stack-hints.md
|
|
61
|
+
└── tasks/
|
|
62
|
+
├── scan.md
|
|
63
|
+
├── init-agent-onboarding.md
|
|
64
|
+
├── readme.md
|
|
65
|
+
├── api-docs.md
|
|
66
|
+
├── stack-docs.md
|
|
67
|
+
├── diagrams.md
|
|
68
|
+
├── flows.md
|
|
69
|
+
├── version-audit.md
|
|
70
|
+
├── check.md
|
|
71
|
+
└── review.md
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
1. Open `prompts/system/base.md` and use it as the agent system or project instruction.
|
|
77
|
+
2. Assemble repo context using the shape in `prompts/partials/context-format.md`.
|
|
78
|
+
3. For documentation generation, run `prompts/tasks/scan.md` first.
|
|
79
|
+
4. For agent onboarding only, run `prompts/tasks/init-agent-onboarding.md` directly.
|
|
80
|
+
5. Pass the scan result into one downstream documentation prompt, such as `prompts/tasks/readme.md` or `prompts/tasks/stack-docs.md`.
|
|
81
|
+
6. Apply generated docs only after checking that every claim is grounded in provided files.
|
|
82
|
+
|
|
83
|
+
## What The Prompts Do
|
|
84
|
+
|
|
85
|
+
| Prompt | Purpose |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `prompts/system/base.md` | Defines the codeforerunner role, quality bar, Markdown rules, and accuracy constraints. |
|
|
88
|
+
| `prompts/tasks/scan.md` | Produces the first structured repo scan used by downstream tasks. |
|
|
89
|
+
| `prompts/tasks/init-agent-onboarding.md` | Generates or updates `AGENTS.md` from repo evidence plus files such as `CLAUDE.md`, `.cursor/rules/*`, `.cursorrules`, `.github/copilot-instructions.md`, and `opencode.json`. |
|
|
90
|
+
| `prompts/tasks/readme.md` | Generates or rewrites a top-level README from scan output and selected files. |
|
|
91
|
+
| `prompts/tasks/api-docs.md` | Documents public APIs when endpoints/interfaces are evident. |
|
|
92
|
+
| `prompts/tasks/stack-docs.md` | Documents stack-specific areas of a repo. |
|
|
93
|
+
| `prompts/tasks/diagrams.md` | Generates Mermaid architecture or flow diagrams. |
|
|
94
|
+
| `prompts/tasks/flows.md` | Documents user, request, job, or data flows. |
|
|
95
|
+
| `prompts/tasks/version-audit.md` | Audits pinned versions from manifests, lockfiles, Dockerfiles, workflows, or IaC. |
|
|
96
|
+
| `prompts/tasks/check.md` | Checks existing docs for staleness against a fresh scan. |
|
|
97
|
+
| `prompts/tasks/review.md` | Summarizes documentation impact for review. |
|
|
98
|
+
|
|
99
|
+
## Docs And Spec
|
|
100
|
+
|
|
101
|
+
- `SPEC.md` tracks phases, invariants, and tasks so future PRs can make small status updates instead of broad rewrites.
|
|
102
|
+
- `docs/getting-started.md` explains manual prompt use.
|
|
103
|
+
- `docs/prompt-guide.md` explains how system, partial, and task prompts compose.
|
|
104
|
+
- `docs/editor-agent-setup.md` explains how to adapt prompts to local agents.
|
|
105
|
+
- `docs/roadmap.md` mirrors the `SPEC.md` phase status in human-readable form.
|
|
106
|
+
- `docs/agent-distribution-design.md` records the design that backs the Codex/Claude packages and `forerunner install`.
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
`forerunner.config.yaml.example` documents the loaded schema. Copy it to `forerunner.config.yaml` to opt in; without that file, `forerunner check` is a silent no-op. The schema has top-level provider/model fields (`provider`, `model`, `api_key_env`, `output_dir`, `context_max_files`, `context_max_lines_per_file`, `approaching_eol_threshold_months`), `ignore_patterns`, `tasks.version_audit`, and `tasks.check`. `forerunner check` honors `tasks.check.enabled_rules` (allowlist of rule IDs, default all) and `tasks.check.ignore_paths` (fnmatch globs applied to scanned docs). Invalid YAML, unknown providers, unknown `api_key_env` providers, or unknown severities surface as a `ConfigError` and exit non-zero.
|
|
111
|
+
|
|
112
|
+
### MCP Server
|
|
113
|
+
|
|
114
|
+
`forerunner mcp-server` speaks JSON-RPC 2.0 over stdio and exposes one tool per `prompts/tasks/*.md` (tool name = filename stem). Each `tools/call` returns the resolved `base + partials + task` bundle as text. A scan-first gate enforces SPEC V2: any tool other than `scan` or `init-agent-onboarding` returns an error until `scan` has been called in the same session. Point any MCP-compatible client at `forerunner mcp-server` as a stdio server (running from the target repo so `prompts/tasks/` resolves).
|
|
115
|
+
|
|
116
|
+
See `examples/mcp/` for Claude Desktop and mcp-cli wiring examples.
|
|
117
|
+
|
|
118
|
+
## Roadmap
|
|
119
|
+
|
|
120
|
+
See `SPEC.md` for the canonical phase/task tracker and `docs/roadmap.md` for the human-readable roadmap.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
codeforerunner/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
2
|
+
codeforerunner/check.py,sha256=kSrVoTVb9ALEBHvUerR2nZfroXco_yz6gMgXzdz45Ac,4905
|
|
3
|
+
codeforerunner/cli.py,sha256=anRbSooRqb5cS4EtgGJHVIOTK1ZgdACWb4WcD5Y0hqM,8478
|
|
4
|
+
codeforerunner/config.py,sha256=REs4FgmSSn7R2tLIwLJPL8VmSCGkTvw8J2SqBOggzho,6206
|
|
5
|
+
codeforerunner/doctor.py,sha256=AFHY8YbqTp726JcxLAYvrdeOLwr6WLPiNUUF2QYOwL8,10076
|
|
6
|
+
codeforerunner/installer.py,sha256=WJkWbZjP7x_CWQDs3cfx8zBr97riMgzmGFRNtruPlgI,10301
|
|
7
|
+
codeforerunner/mcp_server.py,sha256=d8eiBPGTLmf_k78tbH1hCfdyokXQajAgcM40loVXNHI,5737
|
|
8
|
+
codeforerunner/providers/__init__.py,sha256=ttMAbHWJIO8s-8H6Kb_EWf3LN5oMzlmX1D12RyGSmIg,962
|
|
9
|
+
codeforerunner/providers/anthropic.py,sha256=zaJDyzH1vPr7nSqUOv6avlTk3covpkWXLHae2-bS9io,2021
|
|
10
|
+
codeforerunner/providers/base.py,sha256=Jk9vBeRNxH9naUC6stN5jY1KHmhrqg2k2kMGOhbQTYk,752
|
|
11
|
+
codeforerunner/providers/google.py,sha256=wBpbte8hdX_Jnr_JBHvQarogT6T1hWR4aGF5O2exBCU,2109
|
|
12
|
+
codeforerunner/providers/ollama.py,sha256=bKhigdjHs3aw73iB5uQka9_n-XCjv1sh_zZfe1TrWTQ,1975
|
|
13
|
+
codeforerunner/providers/openai.py,sha256=OHWeZ11OuHPgQBMwqJnJWxNHyGIP5KL7B06w2ttgxlY,1952
|
|
14
|
+
codeforerunner-0.3.0.dist-info/licenses/LICENSE.md,sha256=iIhmJHib6GbdjcwiDMM-npiNRf3XgASom1WsOJivEdc,2915
|
|
15
|
+
codeforerunner-0.3.0.dist-info/METADATA,sha256=MsbTrzGM5GeGz_JtKhSONLHtwxbGwIz7A_EyK3vSd_c,7047
|
|
16
|
+
codeforerunner-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
codeforerunner-0.3.0.dist-info/entry_points.txt,sha256=3p8BbPlq-wfcXk42tsweKePRaGlZ1WXho1gOkuZGyIQ,55
|
|
18
|
+
codeforerunner-0.3.0.dist-info/top_level.txt,sha256=pV1rt0-NIpNEotKXpL_sF2060DHr-_0F86LWhUlvXis,15
|
|
19
|
+
codeforerunner-0.3.0.dist-info/RECORD,,
|