cendor-init 0.1.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.
- cendor_init/__init__.py +15 -0
- cendor_init/__main__.py +8 -0
- cendor_init/cli.py +188 -0
- cendor_init/detect.py +226 -0
- cendor_init/doctor.py +259 -0
- cendor_init/initialize.py +239 -0
- cendor_init/io.py +39 -0
- cendor_init/scan.py +58 -0
- cendor_init/semver.py +58 -0
- cendor_init/templates.py +131 -0
- cendor_init/versions.py +26 -0
- cendor_init-0.1.0.dist-info/METADATA +81 -0
- cendor_init-0.1.0.dist-info/RECORD +17 -0
- cendor_init-0.1.0.dist-info/WHEEL +4 -0
- cendor_init-0.1.0.dist-info/entry_points.txt +2 -0
- cendor_init-0.1.0.dist-info/licenses/LICENSE +201 -0
- cendor_init-0.1.0.dist-info/licenses/NOTICE +7 -0
cendor_init/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""cendor-init — offline CLI to wire Cendor + your AI assistant (``init``) and validate the wiring
|
|
2
|
+
(``doctor``). Optional developer tooling; no Cendor library depends on it, and it makes no network
|
|
3
|
+
call. See https://cendor.ai/docs/for-ai-assistants.
|
|
4
|
+
|
|
5
|
+
This is a standalone top-level package (``cendor_init``) — NOT part of the PEP 420 ``cendor.*``
|
|
6
|
+
namespace — mirroring how ``cendor-mcp`` ships ``cendor_mcp``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .doctor import run_doctor
|
|
12
|
+
from .initialize import run_init
|
|
13
|
+
|
|
14
|
+
__all__ = ["run_init", "run_doctor", "__version__"]
|
|
15
|
+
__version__ = "0.1.0"
|
cendor_init/__main__.py
ADDED
cendor_init/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``cendor-init`` — one command to make a project Cendor-ready and Cendor-fluent for its AI
|
|
2
|
+
assistant, plus a ``doctor`` that catches wiring mistakes before they bite. Offline: no network, no key.
|
|
3
|
+
|
|
4
|
+
uvx cendor-init # detect + write assistant rules (idempotent)
|
|
5
|
+
uvx cendor-init doctor # validate wiring; non-zero exit on hard problems (CI-usable)
|
|
6
|
+
|
|
7
|
+
See https://cendor.ai/docs/for-ai-assistants and https://cendor.ai/mcp.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .doctor import SEVERITY_RANK, Finding, run_doctor
|
|
18
|
+
from .initialize import ALL_ASSISTANTS, InitOptions, run_init
|
|
19
|
+
|
|
20
|
+
__all__ = ["main"]
|
|
21
|
+
|
|
22
|
+
_ICON = {
|
|
23
|
+
"created": "+",
|
|
24
|
+
"updated": "~",
|
|
25
|
+
"appended": "~",
|
|
26
|
+
"skipped": "·",
|
|
27
|
+
"would-create": "+",
|
|
28
|
+
"would-update": "~",
|
|
29
|
+
"would-append": "~",
|
|
30
|
+
"would-skip": "·",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version() -> str:
|
|
35
|
+
try:
|
|
36
|
+
from importlib import metadata
|
|
37
|
+
|
|
38
|
+
return metadata.version("cendor-init")
|
|
39
|
+
except Exception:
|
|
40
|
+
return "0.1.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_assistants(raw: list[str] | None) -> list[str] | None:
|
|
44
|
+
if not raw:
|
|
45
|
+
return None
|
|
46
|
+
wanted: list[str] = []
|
|
47
|
+
for item in raw:
|
|
48
|
+
for part in item.split(","):
|
|
49
|
+
part = part.strip().lower()
|
|
50
|
+
if part and part not in wanted:
|
|
51
|
+
wanted.append(part)
|
|
52
|
+
bad = [w for w in wanted if w not in ALL_ASSISTANTS]
|
|
53
|
+
if bad:
|
|
54
|
+
sys.stderr.write(f"cendor-init: unknown assistant(s): {', '.join(bad)}\n")
|
|
55
|
+
sys.stderr.write(f" valid: {', '.join(ALL_ASSISTANTS)}\n")
|
|
56
|
+
raise SystemExit(2)
|
|
57
|
+
return wanted
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
61
|
+
root = Path.cwd()
|
|
62
|
+
opts = InitOptions(
|
|
63
|
+
root=root,
|
|
64
|
+
assistants=_parse_assistants(args.assistant),
|
|
65
|
+
all=args.all,
|
|
66
|
+
mcp=args.mcp,
|
|
67
|
+
scaffold=args.scaffold,
|
|
68
|
+
force=args.force,
|
|
69
|
+
dry_run=args.dry_run,
|
|
70
|
+
)
|
|
71
|
+
result = run_init(opts)
|
|
72
|
+
dry = opts.dry_run
|
|
73
|
+
eco = result.detected.ecosystem
|
|
74
|
+
|
|
75
|
+
out = sys.stdout.write
|
|
76
|
+
out(
|
|
77
|
+
f"\ncendor-init — {'dry run (no files written)' if dry else 'wiring Cendor for your assistant'}\n"
|
|
78
|
+
)
|
|
79
|
+
line = "no package.json / pyproject found" if eco == "unknown" else f"{eco} project"
|
|
80
|
+
if result.detected.declared_providers:
|
|
81
|
+
line += f" · providers: {', '.join(sorted(result.detected.declared_providers))}"
|
|
82
|
+
out(f"project: {line}\n\n")
|
|
83
|
+
|
|
84
|
+
for a in result.actions:
|
|
85
|
+
icon = _ICON.get(a.status, "·")
|
|
86
|
+
note = f" ({a.note})" if a.note else ""
|
|
87
|
+
out(f" {icon} {a.status:<13} {a.path}{note}\n")
|
|
88
|
+
|
|
89
|
+
out("\nMCP (agent-mode assistants):\n")
|
|
90
|
+
for ln in result.mcp_guidance.split("\n"):
|
|
91
|
+
out(f" {ln}\n")
|
|
92
|
+
|
|
93
|
+
out("\nNext:\n")
|
|
94
|
+
out(" • Trust your editor's hover/completion — every Cendor symbol ships an @example.\n")
|
|
95
|
+
out(" • Full trap sheet: https://cendor.ai/docs/for-ai-assistants\n")
|
|
96
|
+
out(" • Validate wiring: uvx cendor-init doctor\n\n")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_finding(f: Finding) -> None:
|
|
101
|
+
tag = {"error": "ERROR", "warn": "WARN ", "ok": "OK "}.get(f.severity, "INFO ")
|
|
102
|
+
out = sys.stdout.write
|
|
103
|
+
out(f" [{tag}] {f.title}\n")
|
|
104
|
+
for ln in textwrap.wrap(f.detail, width=88):
|
|
105
|
+
out(f" {ln}\n")
|
|
106
|
+
if f.fix:
|
|
107
|
+
out(f" fix: {f.fix}\n")
|
|
108
|
+
for loc in f.locations:
|
|
109
|
+
out(f" - {loc}\n")
|
|
110
|
+
out("\n")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cmd_doctor(_args: argparse.Namespace) -> int:
|
|
114
|
+
result = run_doctor(Path.cwd())
|
|
115
|
+
sys.stdout.write("\ncendor-init doctor\n\n")
|
|
116
|
+
for f in sorted(result.findings, key=lambda x: SEVERITY_RANK.get(x.severity, 9)):
|
|
117
|
+
_print_finding(f)
|
|
118
|
+
errors = sum(1 for f in result.findings if f.severity == "error")
|
|
119
|
+
warns = sum(1 for f in result.findings if f.severity == "warn")
|
|
120
|
+
tail = "OK." if result.exit_code == 0 else "Fix the errors above."
|
|
121
|
+
sys.stdout.write(f"{errors} error(s), {warns} warning(s). {tail}\n\n")
|
|
122
|
+
return result.exit_code
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
126
|
+
parser = argparse.ArgumentParser(
|
|
127
|
+
prog="cendor-init",
|
|
128
|
+
description="Wire Cendor + your AI assistant, offline.",
|
|
129
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
130
|
+
epilog="Docs: https://cendor.ai/docs/for-ai-assistants MCP: https://cendor.ai/mcp",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument("-v", "--version", action="version", version=_version())
|
|
133
|
+
sub = parser.add_subparsers(dest="command")
|
|
134
|
+
|
|
135
|
+
p_init = sub.add_parser("init", help="write assistant rules files (default command)")
|
|
136
|
+
_add_init_args(p_init)
|
|
137
|
+
sub.add_parser("doctor", help="validate the wiring (never writes); exit 1 on hard problems")
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _add_init_args(p: argparse.ArgumentParser) -> None:
|
|
142
|
+
p.add_argument("--all", action="store_true", help="write every assistant rules file")
|
|
143
|
+
p.add_argument(
|
|
144
|
+
"--assistant",
|
|
145
|
+
action="append",
|
|
146
|
+
metavar="LIST",
|
|
147
|
+
help="comma-separated subset: copilot,cursor,agents,claude,windsurf",
|
|
148
|
+
)
|
|
149
|
+
p.add_argument("--mcp", action="store_true", help="also drop MCP connect config where absent")
|
|
150
|
+
p.add_argument(
|
|
151
|
+
"--scaffold", action="store_true", help="also write a correct instrument()+budget starter"
|
|
152
|
+
)
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"--force", action="store_true", help="overwrite an owned file (.cursor/rules/cendor.mdc)"
|
|
155
|
+
)
|
|
156
|
+
p.add_argument("--dry-run", action="store_true", help="show what would change without writing")
|
|
157
|
+
p.add_argument("-y", "--yes", action="store_true", help=argparse.SUPPRESS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _force_utf8() -> None:
|
|
161
|
+
"""Our output uses em-dashes / bullets. A Windows console defaults to a non-UTF-8 code page,
|
|
162
|
+
which would raise UnicodeEncodeError on those — force UTF-8 (with a replace fallback)."""
|
|
163
|
+
for stream in (sys.stdout, sys.stderr):
|
|
164
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
165
|
+
if reconfigure is not None:
|
|
166
|
+
try:
|
|
167
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
168
|
+
except (ValueError, OSError): # pragma: no cover - stream doesn't support it
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
_force_utf8()
|
|
174
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
175
|
+
# Default to `init` when no subcommand is given (mirrors `npx @cendor/init`).
|
|
176
|
+
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "-v", "--version")):
|
|
177
|
+
argv = ["init", *argv]
|
|
178
|
+
|
|
179
|
+
parser = _build_parser()
|
|
180
|
+
args = parser.parse_args(argv)
|
|
181
|
+
command = args.command or "init"
|
|
182
|
+
if command == "doctor":
|
|
183
|
+
return _cmd_doctor(args)
|
|
184
|
+
return _cmd_init(args)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__": # pragma: no cover
|
|
188
|
+
raise SystemExit(main())
|
cendor_init/detect.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Detect the project shape, which assistants are configured, which providers / cendor packages exist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# PyPI provider distribution / import token -> normalized provider key.
|
|
12
|
+
PYPI_PROVIDERS: dict[str, str] = {
|
|
13
|
+
"openai": "openai",
|
|
14
|
+
"anthropic": "anthropic",
|
|
15
|
+
"google-genai": "google",
|
|
16
|
+
"google-generativeai": "google",
|
|
17
|
+
"google.genai": "google",
|
|
18
|
+
"google_genai": "google",
|
|
19
|
+
"boto3": "bedrock",
|
|
20
|
+
"ollama": "ollama",
|
|
21
|
+
"mistralai": "mistral",
|
|
22
|
+
"cohere": "cohere",
|
|
23
|
+
"huggingface_hub": "huggingface",
|
|
24
|
+
"huggingface-hub": "huggingface",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# npm provider package -> normalized provider key (for the cross-ecosystem "used in .ts source" note).
|
|
28
|
+
NPM_PROVIDERS: dict[str, str] = {
|
|
29
|
+
"openai": "openai",
|
|
30
|
+
"@anthropic-ai/sdk": "anthropic",
|
|
31
|
+
"@google/genai": "google",
|
|
32
|
+
"@google/generative-ai": "google",
|
|
33
|
+
"@aws-sdk/client-bedrock-runtime": "bedrock",
|
|
34
|
+
"ollama": "ollama",
|
|
35
|
+
"@mistralai/mistralai": "mistral",
|
|
36
|
+
"cohere-ai": "cohere",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# The PyPI distribution that ships each provider SDK (for the "install X" fix hint).
|
|
40
|
+
PYPI_PKG_FOR_PROVIDER: dict[str, str] = {
|
|
41
|
+
"openai": "openai",
|
|
42
|
+
"anthropic": "anthropic",
|
|
43
|
+
"google": "google-genai",
|
|
44
|
+
"bedrock": "boto3",
|
|
45
|
+
"ollama": "ollama",
|
|
46
|
+
"mistral": "mistralai",
|
|
47
|
+
"cohere": "cohere",
|
|
48
|
+
"huggingface": "huggingface_hub",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# The cendor-sdk extra that pulls each provider (nicer fix hint than the bare package).
|
|
52
|
+
SDK_EXTRA_FOR_PROVIDER: dict[str, str] = {
|
|
53
|
+
"openai": "openai",
|
|
54
|
+
"anthropic": "anthropic",
|
|
55
|
+
"google": "google",
|
|
56
|
+
"bedrock": "bedrock",
|
|
57
|
+
"ollama": "ollama",
|
|
58
|
+
"huggingface": "huggingface",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
CENDOR_DISTS = list(
|
|
62
|
+
{
|
|
63
|
+
"cendor-core",
|
|
64
|
+
"cendor-tokenguard",
|
|
65
|
+
"cendor-guardrails",
|
|
66
|
+
"cendor-contextkit",
|
|
67
|
+
"cendor-squeeze",
|
|
68
|
+
"cendor-cassette",
|
|
69
|
+
"cendor-acttrace",
|
|
70
|
+
"cendor-libs",
|
|
71
|
+
"cendor",
|
|
72
|
+
"cendor-sdk",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_PY_IMPORT_RE = re.compile(
|
|
77
|
+
r"(?:^|\n)[ \t]*(?:import|from)[ \t]+"
|
|
78
|
+
r"(openai|anthropic|google\.genai|google_genai|boto3|ollama|mistralai|cohere|huggingface_hub)\b"
|
|
79
|
+
)
|
|
80
|
+
_NPM_IMPORT_RE = re.compile(
|
|
81
|
+
r"""(?:from|require|import)\s*\(?\s*['"]"""
|
|
82
|
+
r"(openai|@anthropic-ai/sdk|@google/genai|@google/generative-ai|"
|
|
83
|
+
r"@aws-sdk/client-bedrock-runtime|ollama|@mistralai/mistralai|cohere-ai)['\"]"
|
|
84
|
+
)
|
|
85
|
+
# A dependency token like `cendor-core>=1.3,<2` or `openai` (quoted or bare) in pyproject/requirements.
|
|
86
|
+
_DEP_RE = re.compile(r"""["'\s]([A-Za-z][A-Za-z0-9._-]*)\s*((?:[<>=!~]=?|==)\s*[0-9][^"',\]]*)?""")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Detected:
|
|
91
|
+
root: Path
|
|
92
|
+
ecosystem: str # "node" | "python" | "unknown"
|
|
93
|
+
node: bool
|
|
94
|
+
python: bool
|
|
95
|
+
declared_providers: set[str] = field(default_factory=set)
|
|
96
|
+
assistants: list[str] = field(default_factory=list)
|
|
97
|
+
# cendor-* -> version spec declared in pyproject/requirements.
|
|
98
|
+
declared_pypi: dict[str, str] = field(default_factory=dict)
|
|
99
|
+
# cendor-* -> installed version (importlib.metadata) if importable in THIS environment.
|
|
100
|
+
installed_pypi: dict[str, str] = field(default_factory=dict)
|
|
101
|
+
# @cendor/* -> range declared in package.json (cross-ecosystem hint).
|
|
102
|
+
declared_npm: dict[str, str] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def installed_version(dist: str) -> str | None:
|
|
106
|
+
try:
|
|
107
|
+
return metadata.version(dist)
|
|
108
|
+
except metadata.PackageNotFoundError:
|
|
109
|
+
return None
|
|
110
|
+
except Exception: # pragma: no cover - defensive
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_py_deps(text: str) -> tuple[dict[str, str], set[str]]:
|
|
115
|
+
cendor: dict[str, str] = {}
|
|
116
|
+
providers: set[str] = set()
|
|
117
|
+
for m in _DEP_RE.finditer(text):
|
|
118
|
+
raw = (m.group(1) or "").lower()
|
|
119
|
+
spec = re.sub(r"\s+", "", m.group(2) or "")
|
|
120
|
+
base = raw.split("[")[0] # strip extras like cendor-sdk[all]
|
|
121
|
+
if base.startswith("cendor"):
|
|
122
|
+
cendor[base] = spec or "*"
|
|
123
|
+
if base in PYPI_PROVIDERS:
|
|
124
|
+
providers.add(PYPI_PROVIDERS[base])
|
|
125
|
+
return cendor, providers
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _detect_assistants(root: Path) -> list[str]:
|
|
129
|
+
found: list[str] = []
|
|
130
|
+
if (root / ".github").exists():
|
|
131
|
+
found.append("copilot")
|
|
132
|
+
if (root / ".cursor").exists():
|
|
133
|
+
found.append("cursor")
|
|
134
|
+
if (root / "AGENTS.md").exists():
|
|
135
|
+
found.append("agents")
|
|
136
|
+
if (root / "CLAUDE.md").exists():
|
|
137
|
+
found.append("claude")
|
|
138
|
+
if (root / ".windsurf").exists():
|
|
139
|
+
found.append("windsurf")
|
|
140
|
+
return found
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _pkg_deps(pkg: dict) -> dict[str, str]:
|
|
144
|
+
out: dict[str, str] = {}
|
|
145
|
+
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
146
|
+
block = pkg.get(key)
|
|
147
|
+
if isinstance(block, dict):
|
|
148
|
+
out.update({str(k): str(v) for k, v in block.items()})
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def detect_project(root: Path) -> Detected:
|
|
153
|
+
root = Path(root)
|
|
154
|
+
node = (root / "package.json").exists()
|
|
155
|
+
python = (
|
|
156
|
+
(root / "pyproject.toml").exists()
|
|
157
|
+
or (root / "setup.py").exists()
|
|
158
|
+
or (root / "setup.cfg").exists()
|
|
159
|
+
or any(
|
|
160
|
+
p.name.startswith("requirements") and p.suffix == ".txt"
|
|
161
|
+
for p in root.glob("requirements*.txt")
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
ecosystem = "node" if node else "python" if python else "unknown"
|
|
165
|
+
|
|
166
|
+
declared_providers: set[str] = set()
|
|
167
|
+
declared_pypi: dict[str, str] = {}
|
|
168
|
+
declared_npm: dict[str, str] = {}
|
|
169
|
+
|
|
170
|
+
if python:
|
|
171
|
+
for fname in ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.cfg"):
|
|
172
|
+
fpath = root / fname
|
|
173
|
+
if not fpath.exists():
|
|
174
|
+
continue
|
|
175
|
+
cendor, provs = _parse_py_deps(fpath.read_text(encoding="utf-8", errors="ignore"))
|
|
176
|
+
declared_pypi.update(cendor)
|
|
177
|
+
declared_providers |= provs
|
|
178
|
+
|
|
179
|
+
if node:
|
|
180
|
+
try:
|
|
181
|
+
pkg = json.loads((root / "package.json").read_text(encoding="utf-8", errors="ignore"))
|
|
182
|
+
for name, ver in _pkg_deps(pkg).items():
|
|
183
|
+
if name in NPM_PROVIDERS:
|
|
184
|
+
declared_providers.add(NPM_PROVIDERS[name])
|
|
185
|
+
if name.startswith("@cendor/"):
|
|
186
|
+
declared_npm[name] = ver
|
|
187
|
+
except (json.JSONDecodeError, OSError):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
# Installed cendor versions from the CURRENT environment (accurate when installed into the
|
|
191
|
+
# project venv; empty under `uvx` isolation, where we fall back to the declared pins above).
|
|
192
|
+
installed_pypi = {d: v for d in CENDOR_DISTS if (v := installed_version(d)) is not None}
|
|
193
|
+
|
|
194
|
+
return Detected(
|
|
195
|
+
root=root,
|
|
196
|
+
ecosystem=ecosystem,
|
|
197
|
+
node=node,
|
|
198
|
+
python=python,
|
|
199
|
+
declared_providers=declared_providers,
|
|
200
|
+
assistants=_detect_assistants(root),
|
|
201
|
+
declared_pypi=declared_pypi,
|
|
202
|
+
installed_pypi=installed_pypi,
|
|
203
|
+
declared_npm=declared_npm,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def providers_used_in_source(text: str, kind: str) -> set[str]:
|
|
208
|
+
used: set[str] = set()
|
|
209
|
+
if kind == "python":
|
|
210
|
+
for m in _PY_IMPORT_RE.finditer(text):
|
|
211
|
+
tok = m.group(1)
|
|
212
|
+
key = PYPI_PROVIDERS.get(tok) or PYPI_PROVIDERS.get(tok.replace(".", "_"))
|
|
213
|
+
if key:
|
|
214
|
+
used.add(key)
|
|
215
|
+
else:
|
|
216
|
+
for m in _NPM_IMPORT_RE.finditer(text):
|
|
217
|
+
key = NPM_PROVIDERS.get(m.group(1))
|
|
218
|
+
if key:
|
|
219
|
+
used.add(key)
|
|
220
|
+
return used
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def provider_installed(provider: str) -> bool:
|
|
224
|
+
"""Is the provider SDK importable in THIS environment?"""
|
|
225
|
+
pkg = PYPI_PKG_FOR_PROVIDER.get(provider)
|
|
226
|
+
return bool(pkg and installed_version(pkg) is not None)
|
cendor_init/doctor.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""``doctor`` — validate the wiring so it doesn't break at runtime. Static checks only; NEVER mutates.
|
|
2
|
+
|
|
3
|
+
Exits non-zero on hard problems (CI-usable), zero when only warnings remain. Python is checked with
|
|
4
|
+
the installed environment (importlib.metadata) when available — accurate — and falls back to the
|
|
5
|
+
declared pins under ``uvx`` isolation. The npm ecosystem gets a "run @cendor/init doctor" pointer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import versions as version_snapshot
|
|
15
|
+
from .detect import (
|
|
16
|
+
PYPI_PKG_FOR_PROVIDER,
|
|
17
|
+
SDK_EXTRA_FOR_PROVIDER,
|
|
18
|
+
Detected,
|
|
19
|
+
detect_project,
|
|
20
|
+
provider_installed,
|
|
21
|
+
providers_used_in_source,
|
|
22
|
+
)
|
|
23
|
+
from .scan import rel, walk_source
|
|
24
|
+
from .semver import clean_version, compare_versions, range_blocks_latest
|
|
25
|
+
|
|
26
|
+
PY_EXTS = (".py",)
|
|
27
|
+
NODE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
|
|
28
|
+
|
|
29
|
+
_MONEY_CONTEXT = re.compile(r"(cost|price|prices|money|usd|\.estimate\s*\(|Money|Decimal)")
|
|
30
|
+
_INSTRUMENT_RE = re.compile(r"\binstrument\s*\(")
|
|
31
|
+
_USES_CENDOR_RE = re.compile(r"from\s+cendor[.\s]|import\s+cendor|@cendor/")
|
|
32
|
+
_BARE_IMPORT_RE = re.compile(r"(?:^|\n)[ \t]*import[ \t]+cendor[ \t]*(?:#.*)?(?:\n|$)")
|
|
33
|
+
_STRAY_INIT_RE = re.compile(r"(?:^|[\\/])cendor[\\/]__init__\.py$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Finding:
|
|
38
|
+
severity: str # "error" | "warn" | "info" | "ok"
|
|
39
|
+
title: str
|
|
40
|
+
detail: str
|
|
41
|
+
fix: str | None = None
|
|
42
|
+
locations: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DoctorResult:
|
|
47
|
+
findings: list[Finding]
|
|
48
|
+
exit_code: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _Src:
|
|
53
|
+
py: list[tuple[str, str]] # (path, text)
|
|
54
|
+
node: list[tuple[str, str]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_all(paths: list[Path]) -> list[tuple[str, str]]:
|
|
58
|
+
out: list[tuple[str, str]] = []
|
|
59
|
+
for p in paths:
|
|
60
|
+
try:
|
|
61
|
+
out.append((str(p), p.read_text(encoding="utf-8", errors="ignore")))
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _index(root: Path) -> _Src:
|
|
68
|
+
return _Src(
|
|
69
|
+
py=_read_all(walk_source(root, PY_EXTS)), node=_read_all(walk_source(root, NODE_EXTS))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _usage(src: _Src) -> tuple[int, bool]:
|
|
74
|
+
count = 0
|
|
75
|
+
uses = False
|
|
76
|
+
for _, text in src.py + src.node:
|
|
77
|
+
count += len(_INSTRUMENT_RE.findall(text))
|
|
78
|
+
if _USES_CENDOR_RE.search(text):
|
|
79
|
+
uses = True
|
|
80
|
+
return count, uses
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_stray_init(root: Path, src: _Src, out: list[Finding]) -> None:
|
|
84
|
+
stray = [rel(root, Path(p)) for p, _ in src.py if _STRAY_INIT_RE.search(p)]
|
|
85
|
+
if stray:
|
|
86
|
+
out.append(
|
|
87
|
+
Finding(
|
|
88
|
+
"error",
|
|
89
|
+
"A top-level cendor/__init__.py exists",
|
|
90
|
+
"`cendor` is a PEP 420 namespace package. A top-level `cendor/__init__.py` in your own "
|
|
91
|
+
"tree shadows the namespace and breaks every `from cendor.<tool> import ...`.",
|
|
92
|
+
"Delete the file. Each Cendor distribution owns only cendor/<tool>/, never cendor/__init__.py.",
|
|
93
|
+
stray,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_bare_import(root: Path, src: _Src, out: list[Finding]) -> None:
|
|
99
|
+
hits = [rel(root, Path(p)) for p, text in src.py if _BARE_IMPORT_RE.search(text)]
|
|
100
|
+
if hits:
|
|
101
|
+
out.append(
|
|
102
|
+
Finding(
|
|
103
|
+
"warn",
|
|
104
|
+
"Bare `import cendor`",
|
|
105
|
+
"`cendor` is a namespace with no module body, so `import cendor` imports nothing usable.",
|
|
106
|
+
"Import from the flat path instead, e.g. `from cendor.tokenguard import budget`.",
|
|
107
|
+
hits[:8],
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_instrument(count: int, uses: bool, out: list[Finding]) -> None:
|
|
113
|
+
if uses and count == 0:
|
|
114
|
+
out.append(
|
|
115
|
+
Finding(
|
|
116
|
+
"warn",
|
|
117
|
+
"No instrument() call found",
|
|
118
|
+
"Cendor is imported but the provider client is never wrapped, so nothing is observed — "
|
|
119
|
+
"budgets, gating, and audit will all see zero calls.",
|
|
120
|
+
"Wrap the client once: `client = instrument(OpenAI())`.",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_money(root: Path, src: _Src, out: list[Finding]) -> None:
|
|
126
|
+
hits: list[str] = []
|
|
127
|
+
for p, text in src.py:
|
|
128
|
+
for line in text.splitlines():
|
|
129
|
+
if re.search(r"\bfloat\s*\(", line) and _MONEY_CONTEXT.search(line):
|
|
130
|
+
hits.append(rel(root, Path(p)))
|
|
131
|
+
break
|
|
132
|
+
for p, text in src.node:
|
|
133
|
+
for line in text.splitlines():
|
|
134
|
+
if (
|
|
135
|
+
re.search(r"\bNumber\s*\(", line) or ".toNumber()" in line
|
|
136
|
+
) and _MONEY_CONTEXT.search(line):
|
|
137
|
+
hits.append(rel(root, Path(p)))
|
|
138
|
+
break
|
|
139
|
+
if hits:
|
|
140
|
+
out.append(
|
|
141
|
+
Finding(
|
|
142
|
+
"warn",
|
|
143
|
+
"Money coerced to a float / number",
|
|
144
|
+
"Cost and price values are Decimal / decimal.js on purpose — converting to float/number "
|
|
145
|
+
"reintroduces the rounding error the Decimal type exists to prevent.",
|
|
146
|
+
"Keep money as Decimal; format only at the edge with str().",
|
|
147
|
+
sorted(set(hits))[:8],
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_py_providers(detected: Detected, src: _Src, out: list[Finding]) -> None:
|
|
153
|
+
used: set[str] = set()
|
|
154
|
+
for _, text in src.py:
|
|
155
|
+
used |= providers_used_in_source(text, "python")
|
|
156
|
+
for p in sorted(used):
|
|
157
|
+
present = p in detected.declared_providers or provider_installed(p)
|
|
158
|
+
if present:
|
|
159
|
+
continue
|
|
160
|
+
pkg = PYPI_PKG_FOR_PROVIDER.get(p, p)
|
|
161
|
+
extra = SDK_EXTRA_FOR_PROVIDER.get(p)
|
|
162
|
+
fix = (
|
|
163
|
+
f'pip install "cendor-sdk[{extra}]" (or `pip install {pkg}`)'
|
|
164
|
+
if extra
|
|
165
|
+
else f"pip install {pkg}"
|
|
166
|
+
)
|
|
167
|
+
out.append(
|
|
168
|
+
Finding(
|
|
169
|
+
"error",
|
|
170
|
+
f'Provider SDK for "{p}" is used but not installed',
|
|
171
|
+
f"Your code imports the {p} SDK, but it is neither declared in pyproject/requirements "
|
|
172
|
+
"nor importable here. Cendor never pulls a provider SDK for you — it is an optional extra.",
|
|
173
|
+
fix,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _check_py_versions(detected: Detected, out: list[Finding]) -> None:
|
|
179
|
+
behind: list[str] = []
|
|
180
|
+
for name, ver in detected.installed_pypi.items():
|
|
181
|
+
latest = version_snapshot.PYPI.get(name)
|
|
182
|
+
have = clean_version(ver)
|
|
183
|
+
if latest and have and compare_versions(have, latest) < 0:
|
|
184
|
+
behind.append(f"{name} {have} (installed) < {latest}")
|
|
185
|
+
for name, spec in detected.declared_pypi.items():
|
|
186
|
+
if name in detected.installed_pypi:
|
|
187
|
+
continue
|
|
188
|
+
latest = version_snapshot.PYPI.get(name)
|
|
189
|
+
if latest and range_blocks_latest(spec, latest):
|
|
190
|
+
behind.append(f'{name} "{spec}" excludes {latest}')
|
|
191
|
+
if behind:
|
|
192
|
+
out.append(
|
|
193
|
+
Finding(
|
|
194
|
+
"warn",
|
|
195
|
+
"A Cendor package looks behind the latest release",
|
|
196
|
+
f"An installed or pinned version trails the bundled snapshot (as of {version_snapshot.AS_OF}). "
|
|
197
|
+
"Type Teach and fixes only arrive on upgrade. This is an offline hint — /releases is canonical.",
|
|
198
|
+
'pip install -U "cendor-sdk" (check https://cendor.ai/releases)',
|
|
199
|
+
behind,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_doctor(root: Path) -> DoctorResult:
|
|
205
|
+
root = Path(root)
|
|
206
|
+
detected = detect_project(root)
|
|
207
|
+
src = _index(root)
|
|
208
|
+
count, uses = _usage(src)
|
|
209
|
+
findings: list[Finding] = []
|
|
210
|
+
|
|
211
|
+
_check_stray_init(root, src, findings)
|
|
212
|
+
_check_bare_import(root, src, findings)
|
|
213
|
+
_check_instrument(count, uses, findings)
|
|
214
|
+
_check_money(root, src, findings)
|
|
215
|
+
|
|
216
|
+
if detected.python:
|
|
217
|
+
_check_py_providers(detected, src, findings)
|
|
218
|
+
_check_py_versions(detected, findings)
|
|
219
|
+
|
|
220
|
+
if detected.node:
|
|
221
|
+
findings.append(
|
|
222
|
+
Finding(
|
|
223
|
+
"info",
|
|
224
|
+
"Node project detected",
|
|
225
|
+
"This CLI checks the Python ecosystem exactly. For the npm ecosystem (peer deps, "
|
|
226
|
+
"@cendor/* versions), run `npx @cendor/init doctor` from the same project.",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
has_cendor = (
|
|
231
|
+
uses
|
|
232
|
+
or bool(detected.installed_pypi)
|
|
233
|
+
or bool(detected.declared_pypi)
|
|
234
|
+
or bool(detected.declared_npm)
|
|
235
|
+
)
|
|
236
|
+
if not has_cendor:
|
|
237
|
+
findings.insert(
|
|
238
|
+
0,
|
|
239
|
+
Finding(
|
|
240
|
+
"info",
|
|
241
|
+
"No Cendor usage detected",
|
|
242
|
+
"Found no Cendor imports or dependencies in this project — nothing to validate.",
|
|
243
|
+
"Run `uvx cendor-init` to wire Cendor + your AI assistant in one step.",
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
elif not any(f.severity in ("error", "warn") for f in findings):
|
|
247
|
+
findings.append(
|
|
248
|
+
Finding(
|
|
249
|
+
"ok",
|
|
250
|
+
"Wiring looks good",
|
|
251
|
+
f"Cendor usage found{f', instrument() called {count}×' if count else ''}; no problems detected.",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
exit_code = 1 if any(f.severity == "error" for f in findings) else 0
|
|
256
|
+
return DoctorResult(findings=findings, exit_code=exit_code)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
SEVERITY_RANK = {"error": 0, "warn": 1, "info": 2, "ok": 3}
|