codeforerunner 0.3.0__tar.gz → 0.3.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.3.0/src/codeforerunner.egg-info → codeforerunner-0.3.2}/PKG-INFO +14 -1
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/pyproject.toml +26 -2
- codeforerunner-0.3.2/src/codeforerunner/__init__.py +6 -0
- codeforerunner-0.3.2/src/codeforerunner/bundle.py +58 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/check.py +116 -23
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/cli.py +63 -48
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/doctor.py +20 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/installer.py +1 -3
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/mcp_server.py +18 -50
- codeforerunner-0.3.2/src/codeforerunner/prompts/partials/context-format.md +29 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/partials/output-rules.md +39 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/partials/stack-hints.md +43 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/system/base.md +38 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/api-docs.md +47 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/audit.md +44 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/changelog.md +42 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/check.md +61 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/diagrams.md +66 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/flows.md +55 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +48 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/readme.md +34 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/review.md +48 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/scan.md +92 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/stack-docs.md +50 -0
- codeforerunner-0.3.2/src/codeforerunner/prompts/tasks/version-audit.md +114 -0
- codeforerunner-0.3.2/src/codeforerunner/providers/anthropic.py +118 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/providers/base.py +9 -1
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/providers/google.py +50 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/providers/ollama.py +45 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/providers/openai.py +54 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2/src/codeforerunner.egg-info}/PKG-INFO +14 -1
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/SOURCES.txt +17 -0
- codeforerunner-0.3.2/tests/test_check.py +402 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_cli.py +82 -11
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_doctor.py +40 -1
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_hooks_manifest.py +4 -2
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_mcp_server.py +6 -3
- codeforerunner-0.3.2/tests/test_providers.py +481 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_workflows_yaml.py +6 -4
- codeforerunner-0.3.0/src/codeforerunner/__init__.py +0 -1
- codeforerunner-0.3.0/src/codeforerunner/providers/anthropic.py +0 -61
- codeforerunner-0.3.0/tests/test_check.py +0 -197
- codeforerunner-0.3.0/tests/test_providers.py +0 -146
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/LICENSE.md +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/README.md +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/setup.cfg +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/config.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner/providers/__init__.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/entry_points.txt +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/requires.txt +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/top_level.txt +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_check_config_integration.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_config.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_examples.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_installer.py +0 -0
- {codeforerunner-0.3.0 → codeforerunner-0.3.2}/tests/test_validate_codex_marketplace.py +0 -0
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeforerunner
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
7
7
|
Project-URL: Repository, https://github.com/derek-palmer/codeforerunner
|
|
8
8
|
Project-URL: Issues, https://github.com/derek-palmer/codeforerunner/issues
|
|
9
|
+
Keywords: repository-documentation,developer-tools,agent-tooling,code-generation,prompt-engineering,mcp,llm,documentation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Documentation
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
9
22
|
Requires-Python: >=3.11
|
|
10
23
|
Description-Content-Type: text/markdown
|
|
11
24
|
License-File: LICENSE.md
|
|
@@ -4,13 +4,37 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codeforerunner"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Model-agnostic repository documentation tooling (prompt-first; thin CLI)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = "LicenseRef-Codeforerunner-SAL-0.1"
|
|
12
12
|
license-files = ["LICENSE.md"]
|
|
13
13
|
authors = [{ name = "Derek Palmer" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"repository-documentation",
|
|
16
|
+
"developer-tools",
|
|
17
|
+
"agent-tooling",
|
|
18
|
+
"code-generation",
|
|
19
|
+
"prompt-engineering",
|
|
20
|
+
"mcp",
|
|
21
|
+
"llm",
|
|
22
|
+
"documentation",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Environment :: Console",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Topic :: Documentation",
|
|
34
|
+
"Topic :: Software Development :: Documentation",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Topic :: Utilities",
|
|
37
|
+
]
|
|
14
38
|
dependencies = [
|
|
15
39
|
"PyYAML>=6.0",
|
|
16
40
|
]
|
|
@@ -26,7 +50,7 @@ forerunner = "codeforerunner.cli:main"
|
|
|
26
50
|
where = ["src"]
|
|
27
51
|
|
|
28
52
|
[tool.setuptools.package-data]
|
|
29
|
-
codeforerunner = ["py.typed"]
|
|
53
|
+
codeforerunner = ["py.typed", "prompts/**/*.md"]
|
|
30
54
|
|
|
31
55
|
[tool.pytest.ini_options]
|
|
32
56
|
testpaths = ["tests"]
|
|
@@ -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"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""Drift detection for docs
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
145
|
-
if
|
|
235
|
+
for rule in active_rules:
|
|
236
|
+
if rule.pattern.search(line):
|
|
146
237
|
violations.append(
|
|
147
|
-
Violation(path=doc, line=lineno, rule_id=
|
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Thin CLI orchestration. Product logic lives in
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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 (
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
125
|
-
cfg = load_from_repo(
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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,
|