recx-cli 0.0.1__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.
recx_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """recx-cli: the validator for RECX.yaml manifests."""
recx_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from recx_cli.cli import main
4
+
5
+ sys.exit(main())
recx_cli/check.py ADDED
@@ -0,0 +1,328 @@
1
+ """Validate a RECX.yaml manifest: the schema plus the rules SPEC.md adds to it.
2
+
3
+ Manifest text is untrusted. It is parsed with a restricted YAML loader, never
4
+ evaluated, and anything echoed back is escaped and truncated.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import json
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+ from jsonschema import Draft202012Validator, FormatChecker
20
+
21
+ MAX_BYTES = 64 * 1024 # SPEC.md rule 5
22
+ ROOT = "/" # how the whole document is named in an error line
23
+ _SCHEMA_NAME = "recx.schema.json"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Problem:
28
+ pointer: str
29
+ message: str
30
+
31
+
32
+ # -- schema ---------------------------------------------------------------
33
+
34
+
35
+ def load_schema() -> dict:
36
+ """The bundled schema; in a source checkout, spec/recx.schema.json itself."""
37
+ try:
38
+ text = (resources.files("recx_cli") / _SCHEMA_NAME).read_text(encoding="utf-8")
39
+ except (FileNotFoundError, OSError):
40
+ # Not built into a wheel (editable install or plain checkout): the one
41
+ # copy under version control sits at <repo>/spec/.
42
+ text = (Path(__file__).resolve().parents[2] / "spec" / _SCHEMA_NAME).read_text(
43
+ encoding="utf-8"
44
+ )
45
+ return json.loads(text)
46
+
47
+
48
+ # -- parsing (rule 6) -----------------------------------------------------
49
+
50
+
51
+ class ManifestError(Exception):
52
+ def __init__(self, message: str, line: int | None = None):
53
+ super().__init__(message)
54
+ self.line = line
55
+
56
+
57
+ _MERGE_TAG = "tag:yaml.org,2002:merge"
58
+
59
+
60
+ class StrictLoader(yaml.SafeLoader):
61
+ """SafeLoader that refuses anchors, aliases, merge keys and repeated keys."""
62
+
63
+ def compose_node(self, parent, index):
64
+ if self.check_event(yaml.events.AliasEvent):
65
+ event = self.peek_event()
66
+ raise ManifestError(
67
+ "aliases (*name) are not allowed (SPEC.md rule 6)",
68
+ event.start_mark.line + 1,
69
+ )
70
+ event = self.peek_event()
71
+ if getattr(event, "anchor", None) is not None:
72
+ raise ManifestError(
73
+ "anchors (&name) are not allowed (SPEC.md rule 6)",
74
+ event.start_mark.line + 1,
75
+ )
76
+ return super().compose_node(parent, index)
77
+
78
+ def construct_mapping(self, node, deep=False):
79
+ if isinstance(node, yaml.MappingNode):
80
+ seen: set = set()
81
+ for key_node, _ in node.value:
82
+ line = key_node.start_mark.line + 1
83
+ if key_node.tag == _MERGE_TAG:
84
+ raise ManifestError(
85
+ "merge keys (<<) are not allowed (SPEC.md rule 6)", line
86
+ )
87
+ if isinstance(key_node, yaml.ScalarNode):
88
+ key = self.construct_object(key_node)
89
+ try:
90
+ if key in seen:
91
+ raise ManifestError(
92
+ f"mapping key {_show(key)} is repeated (SPEC.md rule 6)",
93
+ line,
94
+ )
95
+ seen.add(key)
96
+ except TypeError:
97
+ pass
98
+ return super().construct_mapping(node, deep=deep)
99
+
100
+
101
+ def parse(text: str) -> Any:
102
+ try:
103
+ return yaml.load(text, Loader=StrictLoader) # noqa: S506 - SafeLoader subclass
104
+ except ManifestError:
105
+ raise
106
+ except yaml.YAMLError as exc:
107
+ mark = getattr(exc, "problem_mark", None)
108
+ problem = getattr(exc, "problem", None) or "invalid YAML"
109
+ raise ManifestError(
110
+ f"invalid YAML: {problem}", mark.line + 1 if mark is not None else None
111
+ ) from None
112
+
113
+
114
+ # -- output hygiene -------------------------------------------------------
115
+
116
+
117
+ # Control characters, plus the line separators and bidi controls a terminal would
118
+ # otherwise act on: a bidi override can make echoed manifest text read differently
119
+ # from what it is.
120
+ _UNSAFE = re.compile(r"[^\x20-\x7e -\U0010ffff]|[؜‎‏
-‮⁦-⁩]")
121
+
122
+
123
+ def _clean(text: str, limit: int = 200) -> str:
124
+ if len(text) > limit:
125
+ text = text[:limit] + "..."
126
+ return _UNSAFE.sub(
127
+ lambda m: "\\x%02x" % ord(m.group()) if ord(m.group()) < 256 else "\\u%04x" % ord(m.group()),
128
+ text,
129
+ )
130
+
131
+
132
+ def _show(value: Any) -> str:
133
+ return _clean(repr(value), 80)
134
+
135
+
136
+ def _pointer(parts) -> str:
137
+ if not parts:
138
+ return ROOT
139
+ return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
140
+
141
+
142
+ # -- schema errors, with quoting hints ------------------------------------
143
+
144
+ _YAML_SCALAR_KINDS = (
145
+ (bool, "a boolean"),
146
+ (int, "an integer"),
147
+ (float, "a number"),
148
+ (datetime.datetime, "a date-time"),
149
+ (datetime.date, "a date"),
150
+ )
151
+
152
+
153
+ def _yaml_kind(value: Any) -> str | None:
154
+ for cls, name in _YAML_SCALAR_KINDS:
155
+ if isinstance(value, cls):
156
+ return name
157
+ return None
158
+
159
+
160
+ def _expects_string(error) -> bool:
161
+ if error.validator == "type":
162
+ expected = error.validator_value
163
+ return "string" in (expected if isinstance(expected, list) else [expected])
164
+ if error.validator in ("enum", "const"):
165
+ options = error.validator_value if error.validator == "enum" else [error.validator_value]
166
+ return any(isinstance(o, str) for o in options)
167
+ return False
168
+
169
+
170
+ def _value_text(value: Any) -> str:
171
+ if isinstance(value, bool):
172
+ return "true" if value else "false"
173
+ if isinstance(value, (datetime.date, datetime.datetime)):
174
+ return value.isoformat()
175
+ return str(value)
176
+
177
+
178
+ def schema_problems(data: Any, schema: dict) -> list[Problem]:
179
+ validator = Draft202012Validator(schema, format_checker=FormatChecker())
180
+ errors = sorted(validator.iter_errors(data), key=lambda e: [str(p) for p in e.absolute_path])
181
+ type_error_at = {
182
+ _pointer(e.absolute_path)
183
+ for e in errors
184
+ if e.validator == "type" and _yaml_kind(e.instance) and _expects_string(e)
185
+ }
186
+ problems = []
187
+ for error in errors:
188
+ pointer = _pointer(error.absolute_path)
189
+ kind = _yaml_kind(error.instance)
190
+ if kind and _expects_string(error):
191
+ value = _clean(_value_text(error.instance), 60)
192
+ if error.validator != "type" and pointer in type_error_at:
193
+ continue # the type error says it all
194
+ problems.append(
195
+ Problem(
196
+ pointer,
197
+ f"expected a string, but YAML read {value} as {kind}; "
198
+ f'quote the value (for example "{value}")',
199
+ )
200
+ )
201
+ continue
202
+ problems.append(Problem(pointer, _clean(error.message)))
203
+ return problems
204
+
205
+
206
+ # -- rules 1, 2 and 3 -----------------------------------------------------
207
+
208
+
209
+ def _entries(data: Any, key: str) -> list[tuple[int, dict]]:
210
+ items = data.get(key) if isinstance(data, dict) else None
211
+ if not isinstance(items, list):
212
+ return []
213
+ return [(i, item) for i, item in enumerate(items) if isinstance(item, dict)]
214
+
215
+
216
+ def rule_problems(data: Any) -> list[Problem]:
217
+ problems: list[Problem] = []
218
+ modules = _entries(data, "modules")
219
+ evidence = _entries(data, "evidence")
220
+
221
+ module_ids: set[str] = set()
222
+ first_seen: dict[str, str] = {}
223
+ for i, mod in modules:
224
+ mid = mod.get("id")
225
+ if not isinstance(mid, str):
226
+ continue
227
+ where = f"/modules/{i}/id"
228
+ if mid in first_seen:
229
+ problems.append(
230
+ Problem(where, f"duplicate module id {_show(mid)} (rule 1; first used at {first_seen[mid]})")
231
+ )
232
+ else:
233
+ first_seen[mid] = where
234
+ module_ids.add(mid)
235
+
236
+ seen_ev: dict[str, str] = {}
237
+ for i, ev in evidence:
238
+ eid = ev.get("id")
239
+ if not isinstance(eid, str):
240
+ continue
241
+ where = f"/evidence/{i}/id"
242
+ if eid in seen_ev:
243
+ problems.append(
244
+ Problem(where, f"duplicate evidence id {_show(eid)} (rule 1; first used at {seen_ev[eid]})")
245
+ )
246
+ else:
247
+ seen_ev[eid] = where
248
+
249
+ def check_refs(base: str, refs: Any, what: str) -> None:
250
+ if not isinstance(refs, list):
251
+ return
252
+ for j, ref in enumerate(refs):
253
+ if isinstance(ref, str) and ref not in module_ids:
254
+ problems.append(
255
+ Problem(f"{base}/{j}", f"{what} {_show(ref)} names no module in this manifest (rule 2)")
256
+ )
257
+
258
+ for i, mod in modules:
259
+ requires = mod.get("requires")
260
+ if isinstance(requires, dict):
261
+ check_refs(f"/modules/{i}/requires/modules", requires.get("modules"), "required module")
262
+ for i, ev in evidence:
263
+ check_refs(f"/evidence/{i}/covers", ev.get("covers"), "covered module")
264
+ return problems
265
+
266
+
267
+ def path_problems(data: Any, base: Path, skip: set[str]) -> list[Problem]:
268
+ """Rule 3. Paths the schema already rejected are never touched on disk."""
269
+ problems = []
270
+ for i, mod in _entries(data, "modules"):
271
+ path = mod.get("path")
272
+ pointer = f"/modules/{i}/path"
273
+ if not isinstance(path, str) or pointer in skip:
274
+ continue
275
+ target = base / path
276
+ try:
277
+ exists = target.exists()
278
+ is_dir = target.is_dir()
279
+ except OSError as exc:
280
+ problems.append(Problem(pointer, f"cannot inspect path: {_clean(exc.strerror or 'error')} (rule 3)"))
281
+ continue
282
+ shown = _show(path)
283
+ if not exists:
284
+ problems.append(Problem(pointer, f"path {shown} does not exist relative to the manifest (rule 3)"))
285
+ elif path.endswith("/") and not is_dir:
286
+ problems.append(Problem(pointer, f"path {shown} ends in / but is a file (rule 3)"))
287
+ elif not path.endswith("/") and is_dir:
288
+ problems.append(Problem(pointer, f"path {shown} is a directory, so it must end in / (rule 3)"))
289
+ return problems
290
+
291
+
292
+ # -- entry point ----------------------------------------------------------
293
+
294
+
295
+ class Unreadable(Exception):
296
+ pass
297
+
298
+
299
+ def check_file(path: Path, *, repo: bool = True) -> list[Problem]:
300
+ """Return every problem with the manifest at `path`; raise Unreadable if it cannot be read."""
301
+ try:
302
+ size = path.stat().st_size
303
+ if not path.is_file():
304
+ raise Unreadable("not a regular file")
305
+ if size > MAX_BYTES:
306
+ return [Problem(ROOT, f"file is {size} bytes, over the 64 KiB limit (rule 5)")]
307
+ raw = path.read_bytes()
308
+ except OSError as exc:
309
+ raise Unreadable(_clean(exc.strerror or str(exc))) from None
310
+ if len(raw) > MAX_BYTES:
311
+ return [Problem(ROOT, f"file is {len(raw)} bytes, over the 64 KiB limit (rule 5)")]
312
+ try:
313
+ text = raw.decode("utf-8")
314
+ except UnicodeDecodeError:
315
+ return [Problem(ROOT, "file is not valid UTF-8")]
316
+
317
+ try:
318
+ data = parse(text)
319
+ except ManifestError as exc:
320
+ where = f"line {exc.line}: " if exc.line else ""
321
+ return [Problem(ROOT, f"{where}{_clean(str(exc))}")]
322
+
323
+ problems = schema_problems(data, load_schema())
324
+ problems += rule_problems(data)
325
+ if repo:
326
+ skip = {p.pointer for p in problems}
327
+ problems += path_problems(data, path.resolve().parent, skip)
328
+ return problems
recx_cli/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ """Command line: `recx check [PATH] [--no-repo]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+
10
+ from recx_cli.check import Unreadable, check_file
11
+
12
+
13
+ def _version() -> str:
14
+ try:
15
+ return metadata.version("recx-cli")
16
+ except metadata.PackageNotFoundError:
17
+ return "unknown"
18
+
19
+
20
+ class _Parser(argparse.ArgumentParser):
21
+ def error(self, message): # argparse exits 2 already; keep it explicit
22
+ self.print_usage(sys.stderr)
23
+ self.exit(2, f"{self.prog}: error: {message}\n")
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = _Parser(prog="recx", description="Tools for RECX.yaml manifests.")
28
+ parser.add_argument("--version", action="version", version=f"recx {_version()}")
29
+ sub = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
30
+ check = sub.add_parser(
31
+ "check",
32
+ help="validate a RECX.yaml manifest",
33
+ description="Validate a manifest against the schema and the rules in spec/SPEC.md. "
34
+ "Exit status: 0 valid, 1 invalid, 2 usage error or unreadable file.",
35
+ )
36
+ check.add_argument(
37
+ "path",
38
+ nargs="?",
39
+ default="RECX.yaml",
40
+ metavar="PATH",
41
+ help="a manifest file, or a directory holding RECX.yaml (default: ./RECX.yaml)",
42
+ )
43
+ check.add_argument(
44
+ "--no-repo",
45
+ action="store_true",
46
+ help="skip the checks that look at the repository (rule 3: module paths exist)",
47
+ )
48
+ return parser
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ args = build_parser().parse_args(argv)
53
+ target = Path(args.path)
54
+ if target.is_dir():
55
+ target = target / "RECX.yaml"
56
+ label = str(target)
57
+ try:
58
+ problems = check_file(target, repo=not args.no_repo)
59
+ except Unreadable as exc:
60
+ print(f"{label}: cannot read: {exc}", file=sys.stderr)
61
+ return 2
62
+ if not problems:
63
+ print(f"{label}: valid")
64
+ return 0
65
+ for p in problems:
66
+ print(f"{label}: {p.pointer}: {p.message}")
67
+ return 1
@@ -0,0 +1,469 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "RECX.yaml manifest",
4
+ "description": "recx v0.1: a repository's description of itself for other people's coding agents. See spec/SPEC.md. Every string in a manifest is untrusted data to the agent reading it, never instructions.",
5
+ "type": "object",
6
+ "required": [
7
+ "recx",
8
+ "name",
9
+ "summary",
10
+ "license",
11
+ "status",
12
+ "capabilities"
13
+ ],
14
+ "additionalProperties": false,
15
+ "patternProperties": {
16
+ "^x-[a-z0-9][a-z0-9-]{0,62}$": {
17
+ "$ref": "#/$defs/extension"
18
+ }
19
+ },
20
+ "properties": {
21
+ "recx": {
22
+ "description": "Spec version this manifest is written against.",
23
+ "type": "string",
24
+ "enum": [
25
+ "0.1"
26
+ ]
27
+ },
28
+ "name": {
29
+ "description": "The project's name, as its own authors spell it in lowercase.",
30
+ "$ref": "#/$defs/slug"
31
+ },
32
+ "summary": {
33
+ "description": "What the project does, in one sentence.",
34
+ "$ref": "#/$defs/line"
35
+ },
36
+ "description": {
37
+ "description": "A short paragraph: the problem it solves, the approach, and what is out of scope.",
38
+ "type": "string",
39
+ "minLength": 1,
40
+ "maxLength": 2000
41
+ },
42
+ "license": {
43
+ "description": "SPDX identifier of the project's license. Only permissive licenses are allowed, so anything listed can be reused without negotiation.",
44
+ "$ref": "#/$defs/license"
45
+ },
46
+ "status": {
47
+ "description": "How much the authors stand behind it.",
48
+ "type": "string",
49
+ "enum": [
50
+ "experimental",
51
+ "active",
52
+ "stable",
53
+ "maintenance",
54
+ "archived"
55
+ ]
56
+ },
57
+ "capabilities": {
58
+ "description": "What the project can do, one verb phrase per item. This is what an agent matches an idea against.",
59
+ "type": "array",
60
+ "minItems": 1,
61
+ "maxItems": 30,
62
+ "uniqueItems": true,
63
+ "items": {
64
+ "$ref": "#/$defs/phrase"
65
+ }
66
+ },
67
+ "languages": {
68
+ "description": "Programming languages the project is written in, lowercase.",
69
+ "type": "array",
70
+ "maxItems": 10,
71
+ "uniqueItems": true,
72
+ "items": {
73
+ "$ref": "#/$defs/tag"
74
+ }
75
+ },
76
+ "tags": {
77
+ "description": "Lowercase kebab-case topic tags.",
78
+ "type": "array",
79
+ "maxItems": 20,
80
+ "uniqueItems": true,
81
+ "items": {
82
+ "$ref": "#/$defs/tag"
83
+ }
84
+ },
85
+ "repository": {
86
+ "description": "Canonical URL of the source repository. Optional: an agent that found this file already knows where it is.",
87
+ "$ref": "#/$defs/url"
88
+ },
89
+ "homepage": {
90
+ "description": "Project homepage or documentation URL.",
91
+ "$ref": "#/$defs/url"
92
+ },
93
+ "generator": {
94
+ "description": "The tool that wrote this manifest, with its version, when a tool wrote it.",
95
+ "type": "string",
96
+ "minLength": 1,
97
+ "maxLength": 100
98
+ },
99
+ "modules": {
100
+ "description": "The reusable pieces of the project, each one something another project could take on its own.",
101
+ "type": "array",
102
+ "maxItems": 100,
103
+ "items": {
104
+ "$ref": "#/$defs/module"
105
+ }
106
+ },
107
+ "evidence": {
108
+ "description": "Checks anyone can rerun to see whether the project works.",
109
+ "type": "array",
110
+ "maxItems": 50,
111
+ "items": {
112
+ "$ref": "#/$defs/evidence"
113
+ }
114
+ },
115
+ "reuses": {
116
+ "description": "Work from other repositories this project took and built on. Read across many manifests, these entries are the reuse graph.",
117
+ "type": "array",
118
+ "maxItems": 100,
119
+ "items": {
120
+ "$ref": "#/$defs/reuse"
121
+ }
122
+ }
123
+ },
124
+ "$defs": {
125
+ "slug": {
126
+ "type": "string",
127
+ "maxLength": 64,
128
+ "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
129
+ },
130
+ "id": {
131
+ "type": "string",
132
+ "maxLength": 64,
133
+ "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
134
+ },
135
+ "tag": {
136
+ "type": "string",
137
+ "maxLength": 40,
138
+ "pattern": "^[a-z0-9][a-z0-9+#.-]{0,39}$"
139
+ },
140
+ "line": {
141
+ "type": "string",
142
+ "minLength": 1,
143
+ "maxLength": 160,
144
+ "pattern": "^[^\\n\\r]*$"
145
+ },
146
+ "phrase": {
147
+ "type": "string",
148
+ "minLength": 1,
149
+ "maxLength": 200,
150
+ "pattern": "^[^\\n\\r]*$"
151
+ },
152
+ "command": {
153
+ "description": "One shell command line, run from the repository root.",
154
+ "type": "string",
155
+ "minLength": 1,
156
+ "maxLength": 300,
157
+ "pattern": "^[^\\n\\r]*$"
158
+ },
159
+ "url": {
160
+ "type": "string",
161
+ "format": "uri",
162
+ "maxLength": 300,
163
+ "pattern": "^https://"
164
+ },
165
+ "relpath": {
166
+ "description": "A path relative to the repository root: no leading slash, no '..' segment, no spaces. A trailing slash names a directory.",
167
+ "type": "string",
168
+ "minLength": 1,
169
+ "maxLength": 255,
170
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._@+/-]+$"
171
+ },
172
+ "purl": {
173
+ "description": "A Package URL (https://github.com/package-url/purl-spec), e.g. pkg:pypi/jsonschema or pkg:npm/%40scope/name@1.2.0.",
174
+ "type": "string",
175
+ "maxLength": 200,
176
+ "pattern": "^pkg:[a-z0-9.+-]+/[^\\s]+$"
177
+ },
178
+ "sha": {
179
+ "type": "string",
180
+ "maxLength": 40,
181
+ "pattern": "^[0-9a-f]{7,40}$"
182
+ },
183
+ "extension": {
184
+ "description": "The value of an x- extension key: a short scalar. Consumers ignore keys they do not know.",
185
+ "type": [
186
+ "string",
187
+ "number",
188
+ "boolean"
189
+ ],
190
+ "maxLength": 500
191
+ },
192
+ "license": {
193
+ "type": "string",
194
+ "enum": [
195
+ "0BSD",
196
+ "Apache-2.0",
197
+ "BlueOak-1.0.0",
198
+ "BSD-2-Clause",
199
+ "BSD-3-Clause",
200
+ "BSL-1.0",
201
+ "CC0-1.0",
202
+ "ISC",
203
+ "MIT",
204
+ "MIT-0",
205
+ "UPL-1.0",
206
+ "Unlicense",
207
+ "Zlib"
208
+ ]
209
+ },
210
+ "module": {
211
+ "type": "object",
212
+ "required": [
213
+ "id",
214
+ "path",
215
+ "summary",
216
+ "interface",
217
+ "extract"
218
+ ],
219
+ "additionalProperties": false,
220
+ "patternProperties": {
221
+ "^x-[a-z0-9][a-z0-9-]{0,62}$": {
222
+ "$ref": "#/$defs/extension"
223
+ }
224
+ },
225
+ "properties": {
226
+ "id": {
227
+ "description": "Unique within this manifest.",
228
+ "$ref": "#/$defs/id"
229
+ },
230
+ "path": {
231
+ "description": "Where the module lives: a file, or a directory with a trailing slash.",
232
+ "$ref": "#/$defs/relpath"
233
+ },
234
+ "summary": {
235
+ "description": "What the module does, in one sentence.",
236
+ "$ref": "#/$defs/line"
237
+ },
238
+ "capabilities": {
239
+ "description": "What this module alone can do, when that is narrower than the project's list.",
240
+ "type": "array",
241
+ "maxItems": 20,
242
+ "uniqueItems": true,
243
+ "items": {
244
+ "$ref": "#/$defs/phrase"
245
+ }
246
+ },
247
+ "language": {
248
+ "$ref": "#/$defs/tag"
249
+ },
250
+ "interface": {
251
+ "description": "How the module is called.",
252
+ "type": "object",
253
+ "required": [
254
+ "type",
255
+ "entrypoints"
256
+ ],
257
+ "additionalProperties": false,
258
+ "properties": {
259
+ "type": {
260
+ "type": "string",
261
+ "enum": [
262
+ "library",
263
+ "cli",
264
+ "http-api",
265
+ "mcp-server",
266
+ "github-action",
267
+ "claude-code-plugin",
268
+ "agent-skill",
269
+ "schema",
270
+ "config",
271
+ "other"
272
+ ]
273
+ },
274
+ "entrypoints": {
275
+ "description": "Signatures, commands or endpoints, one per item, as a caller would write them.",
276
+ "type": "array",
277
+ "minItems": 1,
278
+ "maxItems": 30,
279
+ "items": {
280
+ "type": "string",
281
+ "minLength": 1,
282
+ "maxLength": 300,
283
+ "pattern": "^[^\\n\\r]*$"
284
+ }
285
+ }
286
+ }
287
+ },
288
+ "requires": {
289
+ "description": "What the module needs besides itself.",
290
+ "type": "object",
291
+ "additionalProperties": false,
292
+ "properties": {
293
+ "packages": {
294
+ "description": "Third-party packages, as Package URLs.",
295
+ "type": "array",
296
+ "maxItems": 50,
297
+ "uniqueItems": true,
298
+ "items": {
299
+ "$ref": "#/$defs/purl"
300
+ }
301
+ },
302
+ "modules": {
303
+ "description": "Ids of other modules in this manifest.",
304
+ "type": "array",
305
+ "maxItems": 50,
306
+ "uniqueItems": true,
307
+ "items": {
308
+ "$ref": "#/$defs/id"
309
+ }
310
+ }
311
+ }
312
+ },
313
+ "extract": {
314
+ "description": "How another project takes this module.",
315
+ "type": "object",
316
+ "required": [
317
+ "method"
318
+ ],
319
+ "additionalProperties": false,
320
+ "properties": {
321
+ "method": {
322
+ "description": "copy: copy the path into your project. package: install the published package. fork: the module is not separable; fork the repository.",
323
+ "type": "string",
324
+ "enum": [
325
+ "copy",
326
+ "package",
327
+ "fork"
328
+ ]
329
+ },
330
+ "package": {
331
+ "description": "The published package, when method is package.",
332
+ "$ref": "#/$defs/purl"
333
+ },
334
+ "notes": {
335
+ "description": "Anything a copier must change or know, such as a config value or an import path to rewrite.",
336
+ "type": "string",
337
+ "minLength": 1,
338
+ "maxLength": 500
339
+ }
340
+ },
341
+ "if": {
342
+ "properties": {
343
+ "method": {
344
+ "const": "package"
345
+ }
346
+ }
347
+ },
348
+ "then": {
349
+ "required": [
350
+ "package"
351
+ ]
352
+ }
353
+ }
354
+ }
355
+ },
356
+ "evidence": {
357
+ "type": "object",
358
+ "required": [
359
+ "id",
360
+ "run",
361
+ "covers"
362
+ ],
363
+ "additionalProperties": false,
364
+ "patternProperties": {
365
+ "^x-[a-z0-9][a-z0-9-]{0,62}$": {
366
+ "$ref": "#/$defs/extension"
367
+ }
368
+ },
369
+ "properties": {
370
+ "id": {
371
+ "description": "Unique within this manifest.",
372
+ "$ref": "#/$defs/id"
373
+ },
374
+ "summary": {
375
+ "description": "What the check checks, in one sentence.",
376
+ "$ref": "#/$defs/line"
377
+ },
378
+ "setup": {
379
+ "description": "The command that prepares a fresh clone for run, if one is needed.",
380
+ "$ref": "#/$defs/command"
381
+ },
382
+ "run": {
383
+ "description": "The command that performs the check. Exit status 0 means pass.",
384
+ "$ref": "#/$defs/command"
385
+ },
386
+ "covers": {
387
+ "description": "Ids of the modules this check exercises. An empty list means the project as a whole.",
388
+ "type": "array",
389
+ "maxItems": 100,
390
+ "uniqueItems": true,
391
+ "items": {
392
+ "$ref": "#/$defs/id"
393
+ }
394
+ },
395
+ "last_result": {
396
+ "description": "The most recent run. Self-reported unless a consumer reruns it; see SPEC.md, Trust.",
397
+ "type": "object",
398
+ "required": [
399
+ "status",
400
+ "commit",
401
+ "date",
402
+ "runner"
403
+ ],
404
+ "additionalProperties": false,
405
+ "properties": {
406
+ "status": {
407
+ "type": "string",
408
+ "enum": [
409
+ "pass",
410
+ "fail"
411
+ ]
412
+ },
413
+ "commit": {
414
+ "description": "The commit the run checked.",
415
+ "$ref": "#/$defs/sha"
416
+ },
417
+ "date": {
418
+ "description": "When the run finished, RFC 3339.",
419
+ "type": "string",
420
+ "format": "date-time",
421
+ "maxLength": 40
422
+ },
423
+ "runner": {
424
+ "description": "ci: a CI service ran it; local: someone's own machine did.",
425
+ "type": "string",
426
+ "enum": [
427
+ "ci",
428
+ "local"
429
+ ]
430
+ },
431
+ "url": {
432
+ "description": "Where the run's log can be read, such as a CI job page.",
433
+ "$ref": "#/$defs/url"
434
+ }
435
+ }
436
+ }
437
+ }
438
+ },
439
+ "reuse": {
440
+ "type": "object",
441
+ "required": [
442
+ "repository"
443
+ ],
444
+ "additionalProperties": false,
445
+ "properties": {
446
+ "repository": {
447
+ "description": "The repository the work came from.",
448
+ "$ref": "#/$defs/url"
449
+ },
450
+ "module": {
451
+ "description": "The id of the module taken, when the source carries a RECX.yaml.",
452
+ "$ref": "#/$defs/id"
453
+ },
454
+ "path": {
455
+ "description": "The path taken, in the source repository.",
456
+ "$ref": "#/$defs/relpath"
457
+ },
458
+ "commit": {
459
+ "description": "The source commit it was taken at.",
460
+ "$ref": "#/$defs/sha"
461
+ },
462
+ "into": {
463
+ "description": "Where it lives in this repository now.",
464
+ "$ref": "#/$defs/relpath"
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: recx-cli
3
+ Version: 0.0.1
4
+ Summary: Validator for RECX.yaml manifests: the recx check command.
5
+ Project-URL: Homepage, https://github.com/jbmv/recx
6
+ Project-URL: Repository, https://github.com/jbmv/recx
7
+ Author: James Bullard
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: jsonschema[format-nongpl]>=4.18
12
+ Requires-Dist: pyyaml>=6
13
+ Description-Content-Type: text/markdown
14
+
15
+ # recx
16
+
17
+ *res extra commercium*: things outside commerce. A repository drops a `RECX.yaml` at its
18
+ root to tell other people's coding agents what it does, which of its modules can be taken on
19
+ their own, how to take them, and which checks anyone can rerun to see that they work.
20
+
21
+ The format is specified in [spec/SPEC.md](spec/SPEC.md); the schema is
22
+ [spec/recx.schema.json](spec/recx.schema.json).
23
+
24
+ ## Checking a manifest
25
+
26
+ ```sh
27
+ pip install recx-cli # or: uvx --from recx-cli recx check
28
+
29
+ recx check # ./RECX.yaml, including that module paths exist
30
+ recx check path/to/repo # a directory holding RECX.yaml
31
+ recx check --no-repo FILE # the file alone, without looking at the repository
32
+ ```
33
+
34
+ One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. Exit status 0
35
+ means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
36
+ check that the license file agrees with `license` (rule 4 of the spec).
37
+
38
+ The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
39
+
40
+ This README is an interim one. The full README is still to come.
@@ -0,0 +1,10 @@
1
+ recx_cli/__init__.py,sha256=xOr2-4_763vaMHuUpNycY6C9RFRGMZZq9KqMRJlcTVE,55
2
+ recx_cli/__main__.py,sha256=u_9wZdHeDK_XOOL7DuoLe5ENEYCyYn6R51mEYRdRkpI,60
3
+ recx_cli/check.py,sha256=892HT5pGw1WIMzff0kRRT3jWgBli06Jlh49CvKQSEI8,11315
4
+ recx_cli/cli.py,sha256=rGNJ2m9f6bXWDcje5tMcIVuLx1wGzYH1fr120raoKHs,2089
5
+ recx_cli/recx.schema.json,sha256=ig44nYMPpHlnBHA5nsCCcZsTjsCtwhzDtsu62zesVfU,13366
6
+ recx_cli-0.0.1.dist-info/METADATA,sha256=X6vayuHEID_6Z6_eLy9Bctef6SZOo8hyKkdrXcQIMPw,1558
7
+ recx_cli-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ recx_cli-0.0.1.dist-info/entry_points.txt,sha256=2gqAsQLrd8vlNRF7jkzHPbM5RGZA1rFwA9u_IDwwzdA,43
9
+ recx_cli-0.0.1.dist-info/licenses/LICENSE,sha256=bVBvwsmFseFZxdXisG_Aib8rcHlEwzNreODYnMEpKr8,1070
10
+ recx_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ recx = recx_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Bullard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.