recx-cli 0.0.1__tar.gz → 0.0.3__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.
- recx_cli-0.0.3/PKG-INFO +98 -0
- recx_cli-0.0.3/README.md +84 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/pyproject.toml +1 -1
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/SPEC.md +5 -5
- {recx_cli-0.0.1 → recx_cli-0.0.3}/src/recx_cli/check.py +5 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/src/recx_cli/cli.py +40 -3
- recx_cli-0.0.3/src/recx_cli/evidence.py +77 -0
- recx_cli-0.0.3/src/recx_cli/hook.py +89 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/tests/test_check.py +3 -2
- recx_cli-0.0.3/tests/test_evidence.py +137 -0
- recx_cli-0.0.3/tests/test_hook.py +219 -0
- recx_cli-0.0.1/PKG-INFO +0 -40
- recx_cli-0.0.1/README.md +0 -26
- {recx_cli-0.0.1 → recx_cli-0.0.3}/.gitignore +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/LICENSE +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/evidence-without-run.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/http-url.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/license-not-permissive.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/missing-capabilities.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/multiline-summary.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/package-without-purl.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/unknown-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid/unknown-version.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/valid/full.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/valid/minimal.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/spec/recx.schema.json +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/src/recx_cli/__init__.py +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.3}/src/recx_cli/__main__.py +0 -0
recx_cli-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: recx-cli
|
|
3
|
+
Version: 0.0.3
|
|
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 way for agents to find reusable, permissively
|
|
18
|
+
licensed code from solo and small-team projects.
|
|
19
|
+
|
|
20
|
+
A `RECX.yaml` at the root of a repository tells other people's coding agents what the repository
|
|
21
|
+
does, which parts of it they can take on their own, and which checks show those parts work. recx
|
|
22
|
+
is that format, a validator for it, and a Claude Code plugin that keeps the file current.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Coding agents have increased the amount of software that individuals and small teams produce.
|
|
27
|
+
Much of it remains internal tooling, and the projects that are published are hard to find among
|
|
28
|
+
the number of new repositories. Evaluating an unfamiliar open-source project also takes time, so
|
|
29
|
+
writing a similar tool is often the quicker option, and comparable tools get written more than
|
|
30
|
+
once.
|
|
31
|
+
|
|
32
|
+
recx moves that search and evaluation to the agent doing the work, in two parts. The first is
|
|
33
|
+
the manifest: a `RECX.yaml` in each participating repository that describes the project's
|
|
34
|
+
capabilities, its separable modules and their interfaces, and the commands that verify them, in
|
|
35
|
+
a fixed machine-readable form. The second is search: a way for an agent, while planning new
|
|
36
|
+
work, to find manifests with relevant modules, check their evidence, and reuse a module rather
|
|
37
|
+
than write it again, recording where it came from in its own project's manifest.
|
|
38
|
+
|
|
39
|
+
Taking part is meant to cost a repository little. One prompt produces a complete, valid
|
|
40
|
+
manifest, and the Claude Code plugin checks it whenever a session ends, so it stays valid as
|
|
41
|
+
the code changes. No hosted service is involved, because the fixed filename serves as the index.
|
|
42
|
+
On GitHub, manifests can be found by searching code for `path:/(^|\/)recx\.ya?ml$/i`.
|
|
43
|
+
|
|
44
|
+
## The manifest
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
recx: "0.1"
|
|
48
|
+
name: tinyfmt
|
|
49
|
+
summary: Formats byte counts as human-readable strings like 1.2 MB.
|
|
50
|
+
license: MIT
|
|
51
|
+
status: stable
|
|
52
|
+
capabilities:
|
|
53
|
+
- format a byte count as a human-readable size string
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Those six keys are the minimum. The optional parts carry most of the information: modules with
|
|
57
|
+
interfaces and extraction notes, evidence anyone can rerun, and records of what was reused from
|
|
58
|
+
where. [spec/SPEC.md](spec/SPEC.md) defines every field, puts a length limit on every string,
|
|
59
|
+
and sets out how to read a stranger's manifest safely.
|
|
60
|
+
|
|
61
|
+
Only permissively licensed projects qualify (MIT, Apache-2.0, the BSDs and similar), so
|
|
62
|
+
anything listed can be reused without asking.
|
|
63
|
+
|
|
64
|
+
## Get started
|
|
65
|
+
|
|
66
|
+
- **Claude Code:** `/plugin marketplace add jbmv/recx`, then `/plugin install recx@recx`
|
|
67
|
+
(or, on Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx`). Then
|
|
68
|
+
ask Claude to add a RECX.yaml. A Stop hook keeps the manifest valid; set
|
|
69
|
+
`RECX_STOP_HOOK=warn` or `off` to soften or disable it.
|
|
70
|
+
- **Any other agent:** paste the prompt in [ADOPT.md](ADOPT.md).
|
|
71
|
+
- **By hand:** start from the example above, then run `uvx --from recx-cli recx check`.
|
|
72
|
+
|
|
73
|
+
## What's here
|
|
74
|
+
|
|
75
|
+
| | |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
|
|
78
|
+
| `recx-cli` on PyPI | `recx check`, the validator (`pip install recx-cli`) |
|
|
79
|
+
| `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
|
|
80
|
+
| [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
|
|
81
|
+
| [RECX.yaml](RECX.yaml) | recx's own manifest |
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
|
|
86
|
+
version it was written against. `recx check` enforces the whole spec except rule 4, that the
|
|
87
|
+
license file matches `license`. The manifest, the validator and the plugin exist; search, the
|
|
88
|
+
second part described above, is not built yet.
|
|
89
|
+
|
|
90
|
+
## Kyosei
|
|
91
|
+
|
|
92
|
+
I built recx for Kyosei, a record-keeping tool for running projects with coding agents. Kyosei
|
|
93
|
+
isn't public yet. It will generate its own RECX.yaml from its records. Nothing in the spec
|
|
94
|
+
depends on Kyosei: it is one client of the spec, like any other tool.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
recx_cli-0.0.3/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# recx
|
|
2
|
+
|
|
3
|
+
*res extra commercium*: things outside commerce. A way for agents to find reusable, permissively
|
|
4
|
+
licensed code from solo and small-team projects.
|
|
5
|
+
|
|
6
|
+
A `RECX.yaml` at the root of a repository tells other people's coding agents what the repository
|
|
7
|
+
does, which parts of it they can take on their own, and which checks show those parts work. recx
|
|
8
|
+
is that format, a validator for it, and a Claude Code plugin that keeps the file current.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Coding agents have increased the amount of software that individuals and small teams produce.
|
|
13
|
+
Much of it remains internal tooling, and the projects that are published are hard to find among
|
|
14
|
+
the number of new repositories. Evaluating an unfamiliar open-source project also takes time, so
|
|
15
|
+
writing a similar tool is often the quicker option, and comparable tools get written more than
|
|
16
|
+
once.
|
|
17
|
+
|
|
18
|
+
recx moves that search and evaluation to the agent doing the work, in two parts. The first is
|
|
19
|
+
the manifest: a `RECX.yaml` in each participating repository that describes the project's
|
|
20
|
+
capabilities, its separable modules and their interfaces, and the commands that verify them, in
|
|
21
|
+
a fixed machine-readable form. The second is search: a way for an agent, while planning new
|
|
22
|
+
work, to find manifests with relevant modules, check their evidence, and reuse a module rather
|
|
23
|
+
than write it again, recording where it came from in its own project's manifest.
|
|
24
|
+
|
|
25
|
+
Taking part is meant to cost a repository little. One prompt produces a complete, valid
|
|
26
|
+
manifest, and the Claude Code plugin checks it whenever a session ends, so it stays valid as
|
|
27
|
+
the code changes. No hosted service is involved, because the fixed filename serves as the index.
|
|
28
|
+
On GitHub, manifests can be found by searching code for `path:/(^|\/)recx\.ya?ml$/i`.
|
|
29
|
+
|
|
30
|
+
## The manifest
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
recx: "0.1"
|
|
34
|
+
name: tinyfmt
|
|
35
|
+
summary: Formats byte counts as human-readable strings like 1.2 MB.
|
|
36
|
+
license: MIT
|
|
37
|
+
status: stable
|
|
38
|
+
capabilities:
|
|
39
|
+
- format a byte count as a human-readable size string
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Those six keys are the minimum. The optional parts carry most of the information: modules with
|
|
43
|
+
interfaces and extraction notes, evidence anyone can rerun, and records of what was reused from
|
|
44
|
+
where. [spec/SPEC.md](spec/SPEC.md) defines every field, puts a length limit on every string,
|
|
45
|
+
and sets out how to read a stranger's manifest safely.
|
|
46
|
+
|
|
47
|
+
Only permissively licensed projects qualify (MIT, Apache-2.0, the BSDs and similar), so
|
|
48
|
+
anything listed can be reused without asking.
|
|
49
|
+
|
|
50
|
+
## Get started
|
|
51
|
+
|
|
52
|
+
- **Claude Code:** `/plugin marketplace add jbmv/recx`, then `/plugin install recx@recx`
|
|
53
|
+
(or, on Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx`). Then
|
|
54
|
+
ask Claude to add a RECX.yaml. A Stop hook keeps the manifest valid; set
|
|
55
|
+
`RECX_STOP_HOOK=warn` or `off` to soften or disable it.
|
|
56
|
+
- **Any other agent:** paste the prompt in [ADOPT.md](ADOPT.md).
|
|
57
|
+
- **By hand:** start from the example above, then run `uvx --from recx-cli recx check`.
|
|
58
|
+
|
|
59
|
+
## What's here
|
|
60
|
+
|
|
61
|
+
| | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
|
|
64
|
+
| `recx-cli` on PyPI | `recx check`, the validator (`pip install recx-cli`) |
|
|
65
|
+
| `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
|
|
66
|
+
| [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
|
|
67
|
+
| [RECX.yaml](RECX.yaml) | recx's own manifest |
|
|
68
|
+
|
|
69
|
+
## Status
|
|
70
|
+
|
|
71
|
+
Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
|
|
72
|
+
version it was written against. `recx check` enforces the whole spec except rule 4, that the
|
|
73
|
+
license file matches `license`. The manifest, the validator and the plugin exist; search, the
|
|
74
|
+
second part described above, is not built yet.
|
|
75
|
+
|
|
76
|
+
## Kyosei
|
|
77
|
+
|
|
78
|
+
I built recx for Kyosei, a record-keeping tool for running projects with coding agents. Kyosei
|
|
79
|
+
isn't public yet. It will generate its own RECX.yaml from its records. Nothing in the spec
|
|
80
|
+
depends on Kyosei: it is one client of the spec, like any other tool.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -7,11 +7,11 @@ written against, so a change never silently reinterprets an old file.
|
|
|
7
7
|
|
|
8
8
|
## Purpose
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
be taken on their own, how to take them, and which checks anyone can rerun to
|
|
14
|
-
work.
|
|
10
|
+
recx is meant to let coding agents find and evaluate reusable code from solo and small-team
|
|
11
|
+
projects, which are otherwise difficult to discover among the volume of new repositories.
|
|
12
|
+
`RECX.yaml` states, in a fixed machine-readable form, what a repository does, which of its parts
|
|
13
|
+
can be taken on their own, how to take them, and which checks anyone can rerun to confirm that
|
|
14
|
+
they work.
|
|
15
15
|
|
|
16
16
|
It complements `AGENTS.md` rather than replacing it. `AGENTS.md` tells an agent how to work
|
|
17
17
|
*inside* a repository. `RECX.yaml` tells an agent working somewhere else what it can take
|
|
@@ -29,6 +29,11 @@ class Problem:
|
|
|
29
29
|
message: str
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def format_problem(label: str, problem: Problem) -> str:
|
|
33
|
+
"""One problem as `recx check` prints it."""
|
|
34
|
+
return f"{label}: {problem.pointer}: {problem.message}"
|
|
35
|
+
|
|
36
|
+
|
|
32
37
|
# -- schema ---------------------------------------------------------------
|
|
33
38
|
|
|
34
39
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Command line: `recx check
|
|
1
|
+
"""Command line: `recx check`, `recx evidence`, `recx hook stop`."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -7,7 +7,9 @@ import sys
|
|
|
7
7
|
from importlib import metadata
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from recx_cli.check import Unreadable, check_file
|
|
10
|
+
from recx_cli.check import Unreadable, check_file, format_problem
|
|
11
|
+
from recx_cli.evidence import HELP as EVIDENCE_HELP
|
|
12
|
+
from recx_cli.evidence import evidence
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def _version() -> str:
|
|
@@ -45,11 +47,46 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
45
47
|
action="store_true",
|
|
46
48
|
help="skip the checks that look at the repository (rule 3: module paths exist)",
|
|
47
49
|
)
|
|
50
|
+
ev = sub.add_parser(
|
|
51
|
+
"evidence",
|
|
52
|
+
help="run a manifest's evidence commands (your own repository or a sandbox only)",
|
|
53
|
+
description=EVIDENCE_HELP,
|
|
54
|
+
)
|
|
55
|
+
ev.add_argument(
|
|
56
|
+
"path",
|
|
57
|
+
nargs="?",
|
|
58
|
+
default="RECX.yaml",
|
|
59
|
+
metavar="PATH",
|
|
60
|
+
help="a manifest file, or a directory holding RECX.yaml (default: ./RECX.yaml)",
|
|
61
|
+
)
|
|
62
|
+
ev.add_argument(
|
|
63
|
+
"--list", action="store_true", help="print each selected entry's commands without running anything"
|
|
64
|
+
)
|
|
65
|
+
ev.add_argument("--only", nargs="+", metavar="ID", help="run only the named evidence entries")
|
|
66
|
+
hook = sub.add_parser(
|
|
67
|
+
"hook",
|
|
68
|
+
help="entry points for Claude Code hooks",
|
|
69
|
+
description="Commands that Claude Code hooks run. They read the hook's JSON on stdin.",
|
|
70
|
+
)
|
|
71
|
+
hook_sub = hook.add_subparsers(dest="hook_command", required=True, parser_class=_Parser)
|
|
72
|
+
hook_sub.add_parser(
|
|
73
|
+
"stop",
|
|
74
|
+
help="Stop hook: keep the repository's RECX.yaml valid as a session ends",
|
|
75
|
+
description="Read Claude Code's Stop hook JSON on stdin. Always exits 0. "
|
|
76
|
+
"RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
|
|
77
|
+
"warn only gives feedback, off does nothing.",
|
|
78
|
+
)
|
|
48
79
|
return parser
|
|
49
80
|
|
|
50
81
|
|
|
51
82
|
def main(argv: list[str] | None = None) -> int:
|
|
52
83
|
args = build_parser().parse_args(argv)
|
|
84
|
+
if args.command == "hook":
|
|
85
|
+
from recx_cli.hook import stop
|
|
86
|
+
|
|
87
|
+
return stop()
|
|
88
|
+
if args.command == "evidence":
|
|
89
|
+
return evidence(args.path, args.list, args.only)
|
|
53
90
|
target = Path(args.path)
|
|
54
91
|
if target.is_dir():
|
|
55
92
|
target = target / "RECX.yaml"
|
|
@@ -63,5 +100,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
63
100
|
print(f"{label}: valid")
|
|
64
101
|
return 0
|
|
65
102
|
for p in problems:
|
|
66
|
-
print(
|
|
103
|
+
print(format_problem(label, p))
|
|
67
104
|
return 1
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`recx evidence`: run a manifest's own evidence commands, in the repository owner's CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from recx_cli.check import Unreadable, check_file, format_problem, parse
|
|
11
|
+
|
|
12
|
+
HELP = (
|
|
13
|
+
"Run each evidence entry's setup and run commands (sh -c, from the manifest's directory, "
|
|
14
|
+
"with your environment) and report pass or fail. This runs the manifest's commands: use it "
|
|
15
|
+
"on your own repository or inside a sandbox, never on a stranger's manifest outside one "
|
|
16
|
+
"(SPEC, Trust). The manifest must pass `recx check` first. "
|
|
17
|
+
"Exit status: 0 all selected entries passed, 1 an entry failed or the manifest is invalid, "
|
|
18
|
+
"2 usage error or unreadable file."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(command: str, cwd: Path) -> int:
|
|
23
|
+
sys.stdout.flush()
|
|
24
|
+
sys.stderr.flush()
|
|
25
|
+
return subprocess.run(["sh", "-c", command], cwd=cwd).returncode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def evidence(path: str, list_only: bool = False, only: list[str] | None = None) -> int:
|
|
29
|
+
target = Path(path)
|
|
30
|
+
if target.is_dir():
|
|
31
|
+
target = target / "RECX.yaml"
|
|
32
|
+
label = str(target)
|
|
33
|
+
try:
|
|
34
|
+
problems = check_file(target, repo=True)
|
|
35
|
+
except Unreadable as exc:
|
|
36
|
+
print(f"{label}: cannot read: {exc}", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
if problems:
|
|
39
|
+
for p in problems:
|
|
40
|
+
print(format_problem(label, p))
|
|
41
|
+
return 1
|
|
42
|
+
entries = parse(target.read_text(encoding="utf-8")).get("evidence") or []
|
|
43
|
+
if only:
|
|
44
|
+
known = [e["id"] for e in entries]
|
|
45
|
+
unknown = [i for i in only if i not in known]
|
|
46
|
+
if unknown:
|
|
47
|
+
print(
|
|
48
|
+
f"recx evidence: no evidence entry with id {', '.join(unknown)} "
|
|
49
|
+
f"(known: {', '.join(known) or 'none'})",
|
|
50
|
+
file=sys.stderr,
|
|
51
|
+
)
|
|
52
|
+
return 2
|
|
53
|
+
entries = [e for e in entries if e["id"] in only]
|
|
54
|
+
if list_only:
|
|
55
|
+
for e in entries:
|
|
56
|
+
print(e["id"])
|
|
57
|
+
if "setup" in e:
|
|
58
|
+
print(f" setup: {e['setup']}")
|
|
59
|
+
print(f" run: {e['run']}")
|
|
60
|
+
return 0
|
|
61
|
+
cwd = target.resolve().parent
|
|
62
|
+
failed = False
|
|
63
|
+
for e in entries:
|
|
64
|
+
start = time.monotonic()
|
|
65
|
+
code = 0
|
|
66
|
+
for step in ("setup", "run"):
|
|
67
|
+
if step in e:
|
|
68
|
+
code = _run(e[step], cwd)
|
|
69
|
+
if code != 0:
|
|
70
|
+
break
|
|
71
|
+
secs = f"{time.monotonic() - start:.1f}s"
|
|
72
|
+
if code == 0:
|
|
73
|
+
print(f"{e['id']}: pass ({secs})", flush=True)
|
|
74
|
+
else:
|
|
75
|
+
failed = True
|
|
76
|
+
print(f"{e['id']}: fail ({secs}, exit {code})", flush=True)
|
|
77
|
+
return 1 if failed else 0
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`recx hook stop`: Claude Code's Stop hook, keeping a RECX.yaml valid as a session ends.
|
|
2
|
+
|
|
3
|
+
The hook must never trap a session on its own failure, so every error path
|
|
4
|
+
exits 0 without output. Only an invalid manifest produces output, and it is
|
|
5
|
+
always JSON on stdout with exit status 0.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from recx_cli.check import Unreadable, check_file, format_problem
|
|
17
|
+
|
|
18
|
+
MANIFEST = "RECX.yaml"
|
|
19
|
+
SKILL = "recx:manifest"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mode() -> str:
|
|
23
|
+
value = os.environ.get("RECX_STOP_HOOK", "block").strip().lower()
|
|
24
|
+
return value if value in ("block", "warn", "off") else "block"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _manifest_dir(cwd: str) -> Path:
|
|
28
|
+
start = Path(cwd)
|
|
29
|
+
try:
|
|
30
|
+
out = subprocess.run(
|
|
31
|
+
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
top = out.stdout.strip()
|
|
37
|
+
if out.returncode == 0 and top:
|
|
38
|
+
return Path(top)
|
|
39
|
+
except (OSError, subprocess.SubprocessError):
|
|
40
|
+
pass
|
|
41
|
+
return start
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run() -> None:
|
|
45
|
+
if _mode() == "off":
|
|
46
|
+
return
|
|
47
|
+
raw = sys.stdin.read()
|
|
48
|
+
if not raw.strip():
|
|
49
|
+
return
|
|
50
|
+
event = json.loads(raw)
|
|
51
|
+
if not isinstance(event, dict):
|
|
52
|
+
return
|
|
53
|
+
cwd = event.get("cwd")
|
|
54
|
+
if not isinstance(cwd, str) or not cwd:
|
|
55
|
+
return
|
|
56
|
+
manifest = _manifest_dir(cwd) / MANIFEST
|
|
57
|
+
if not manifest.is_file():
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
problems = check_file(manifest, repo=True)
|
|
61
|
+
except Unreadable:
|
|
62
|
+
return
|
|
63
|
+
if not problems:
|
|
64
|
+
return
|
|
65
|
+
lines = "\n".join(format_problem(MANIFEST, p) for p in problems)
|
|
66
|
+
if _mode() == "block" and event.get("stop_hook_active") is not True:
|
|
67
|
+
reason = (
|
|
68
|
+
f"{MANIFEST} is invalid. Fix every problem below, using the {SKILL} skill, "
|
|
69
|
+
f"then check it with `recx check` (or `uvx --from recx-cli recx check`):\n{lines}"
|
|
70
|
+
)
|
|
71
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
72
|
+
else:
|
|
73
|
+
still = " still" if event.get("stop_hook_active") is True else ""
|
|
74
|
+
context = (
|
|
75
|
+
f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
|
|
76
|
+
)
|
|
77
|
+
print(
|
|
78
|
+
json.dumps(
|
|
79
|
+
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def stop() -> int:
|
|
85
|
+
try:
|
|
86
|
+
_run()
|
|
87
|
+
except BaseException: # noqa: BLE001 - a hook failure must not block the session
|
|
88
|
+
pass
|
|
89
|
+
return 0
|
|
@@ -238,8 +238,9 @@ def test_build_produces_sdist_and_wheel(tmp_path):
|
|
|
238
238
|
out = tmp_path / "dist"
|
|
239
239
|
env = {k: v for k, v in __import__("os").environ.items() if k != "VIRTUAL_ENV"}
|
|
240
240
|
subprocess.run(["uv", "build", "--out-dir", str(out)], cwd=ROOT, check=True, capture_output=True, env=env)
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
version = __import__("tomllib").loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
|
|
242
|
+
wheels = list(out.glob(f"recx_cli-{version}-*.whl"))
|
|
243
|
+
sdists = list(out.glob(f"recx_cli-{version}.tar.gz"))
|
|
243
244
|
assert len(wheels) == 1 and len(sdists) == 1
|
|
244
245
|
with zipfile.ZipFile(wheels[0]) as z:
|
|
245
246
|
names = z.namelist()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from recx_cli.cli import main
|
|
4
|
+
|
|
5
|
+
HEAD = """recx: "0.1"
|
|
6
|
+
name: example
|
|
7
|
+
summary: An example project.
|
|
8
|
+
license: MIT
|
|
9
|
+
status: experimental
|
|
10
|
+
capabilities:
|
|
11
|
+
- do a thing
|
|
12
|
+
modules:
|
|
13
|
+
- id: m
|
|
14
|
+
path: RECX.yaml
|
|
15
|
+
summary: The manifest.
|
|
16
|
+
interface:
|
|
17
|
+
type: other
|
|
18
|
+
entrypoints: ["x"]
|
|
19
|
+
extract:
|
|
20
|
+
method: copy
|
|
21
|
+
evidence:
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def manifest(tmp_path, body):
|
|
26
|
+
body = body.replace(" summary: s\n", " summary: s\n covers: [m]\n")
|
|
27
|
+
(tmp_path / "RECX.yaml").write_text(HEAD + body)
|
|
28
|
+
return tmp_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(capsys, *argv):
|
|
32
|
+
code = main(list(argv))
|
|
33
|
+
out = capsys.readouterr()
|
|
34
|
+
return code, out.out, out.err
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_runs_setup_then_run_in_order_from_manifest_dir(tmp_path, capsys, monkeypatch):
|
|
38
|
+
d = manifest(
|
|
39
|
+
tmp_path,
|
|
40
|
+
" - id: first\n summary: s\n setup: echo setup >> log\n run: echo first >> log\n"
|
|
41
|
+
" - id: second\n summary: s\n run: echo second >> log\n",
|
|
42
|
+
)
|
|
43
|
+
monkeypatch.chdir(tmp_path.parent) # not the manifest dir
|
|
44
|
+
code, out, _ = run(capsys, "evidence", str(d))
|
|
45
|
+
assert code == 0
|
|
46
|
+
assert (d / "log").read_text().split() == ["setup", "first", "second"]
|
|
47
|
+
lines = out.strip().splitlines()
|
|
48
|
+
assert re.fullmatch(r"first: pass \(\d+\.\d+s\)", lines[0])
|
|
49
|
+
assert re.fullmatch(r"second: pass \(\d+\.\d+s\)", lines[1])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_command_output_streams_through(tmp_path, capfd):
|
|
53
|
+
d = manifest(tmp_path, " - id: a\n summary: s\n run: echo hello-from-child\n")
|
|
54
|
+
assert main(["evidence", str(d)]) == 0
|
|
55
|
+
assert "hello-from-child" in capfd.readouterr().out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_failure_reports_exit_code_and_exits_1(tmp_path, capsys):
|
|
59
|
+
d = manifest(
|
|
60
|
+
tmp_path,
|
|
61
|
+
" - id: bad\n summary: s\n run: exit 3\n"
|
|
62
|
+
" - id: good\n summary: s\n run: 'true'\n",
|
|
63
|
+
)
|
|
64
|
+
code, out, _ = run(capsys, "evidence", str(d / "RECX.yaml"))
|
|
65
|
+
assert code == 1
|
|
66
|
+
lines = out.strip().splitlines()
|
|
67
|
+
assert re.fullmatch(r"bad: fail \(\d+\.\d+s, exit 3\)", lines[0])
|
|
68
|
+
assert lines[1].startswith("good: pass")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_failed_setup_skips_run(tmp_path, capsys):
|
|
72
|
+
d = manifest(
|
|
73
|
+
tmp_path,
|
|
74
|
+
" - id: a\n summary: s\n setup: exit 4\n run: touch ran\n",
|
|
75
|
+
)
|
|
76
|
+
code, out, _ = run(capsys, "evidence", str(d))
|
|
77
|
+
assert code == 1
|
|
78
|
+
assert "a: fail" in out and "exit 4" in out
|
|
79
|
+
assert not (d / "ran").exists()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_list_prints_commands_and_runs_nothing(tmp_path, capsys):
|
|
83
|
+
d = manifest(
|
|
84
|
+
tmp_path,
|
|
85
|
+
" - id: a\n summary: s\n setup: touch ran\n run: touch ran2\n",
|
|
86
|
+
)
|
|
87
|
+
code, out, _ = run(capsys, "evidence", str(d), "--list")
|
|
88
|
+
assert code == 0
|
|
89
|
+
assert "a" in out and "setup: touch ran" in out and "run: touch ran2" in out
|
|
90
|
+
assert not (d / "ran").exists() and not (d / "ran2").exists()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_only_selects_entries(tmp_path, capsys):
|
|
94
|
+
d = manifest(
|
|
95
|
+
tmp_path,
|
|
96
|
+
" - id: a\n summary: s\n run: touch a\n"
|
|
97
|
+
" - id: b\n summary: s\n run: touch b\n"
|
|
98
|
+
" - id: c\n summary: s\n run: touch c\n",
|
|
99
|
+
)
|
|
100
|
+
code, out, _ = run(capsys, "evidence", str(d), "--only", "c", "a")
|
|
101
|
+
assert code == 0
|
|
102
|
+
assert (d / "a").exists() and (d / "c").exists() and not (d / "b").exists()
|
|
103
|
+
assert [l.split(":")[0] for l in out.strip().splitlines()] == ["a", "c"]
|
|
104
|
+
code, out, _ = run(capsys, "evidence", str(d), "--list", "--only", "b")
|
|
105
|
+
assert out.splitlines()[0] == "b" and "a" not in out.split()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_unknown_only_id_is_usage_error(tmp_path, capsys):
|
|
109
|
+
d = manifest(tmp_path, " - id: a\n summary: s\n run: touch a\n")
|
|
110
|
+
code, _, err = run(capsys, "evidence", str(d), "--only", "nope")
|
|
111
|
+
assert code == 2 and "nope" in err
|
|
112
|
+
assert not (d / "a").exists()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_unreadable_file_is_usage_error(tmp_path, capsys):
|
|
116
|
+
code, _, err = run(capsys, "evidence", str(tmp_path / "missing.yaml"))
|
|
117
|
+
assert code == 2 and "cannot read" in err
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_invalid_manifest_refuses_and_runs_nothing(tmp_path, capsys):
|
|
121
|
+
d = manifest(
|
|
122
|
+
tmp_path,
|
|
123
|
+
" - id: a\n summary: s\n run: touch ran\n bogus_field: 1\n",
|
|
124
|
+
)
|
|
125
|
+
code, out, _ = run(capsys, "evidence", str(d))
|
|
126
|
+
assert code == 1
|
|
127
|
+
assert "bogus_field" in out and "pass" not in out
|
|
128
|
+
assert not (d / "ran").exists()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_help_warns_about_strangers(capsys):
|
|
132
|
+
try:
|
|
133
|
+
main(["evidence", "--help"])
|
|
134
|
+
except SystemExit:
|
|
135
|
+
pass
|
|
136
|
+
out = capsys.readouterr().out
|
|
137
|
+
assert "your own repository or inside a sandbox" in " ".join(out.split())
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from recx_cli import hook
|
|
11
|
+
from recx_cli.cli import main
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
SCRIPT = ROOT / "plugin" / "scripts" / "stop-hook.sh"
|
|
15
|
+
|
|
16
|
+
VALID = """recx: "0.1"
|
|
17
|
+
name: example
|
|
18
|
+
summary: An example project.
|
|
19
|
+
license: MIT
|
|
20
|
+
status: experimental
|
|
21
|
+
capabilities:
|
|
22
|
+
- do one thing
|
|
23
|
+
"""
|
|
24
|
+
INVALID = VALID.replace("status: experimental\n", "status: bogus\n")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def _no_env(monkeypatch):
|
|
29
|
+
monkeypatch.delenv("RECX_STOP_HOOK", raising=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def stop(capsys, monkeypatch, stdin):
|
|
33
|
+
if not isinstance(stdin, str):
|
|
34
|
+
stdin = json.dumps(stdin)
|
|
35
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
|
|
36
|
+
code = main(["hook", "stop"])
|
|
37
|
+
out = capsys.readouterr()
|
|
38
|
+
return code, out.out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def event(cwd, **kw):
|
|
42
|
+
return {"hook_event_name": "Stop", "cwd": str(cwd), "stop_hook_active": False, **kw}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def git_init(path):
|
|
46
|
+
subprocess.run(["git", "init", "-q", str(path)], check=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_no_manifest_is_silent(capsys, monkeypatch, tmp_path):
|
|
50
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_valid_manifest_is_silent(capsys, monkeypatch, tmp_path):
|
|
54
|
+
(tmp_path / "RECX.yaml").write_text(VALID)
|
|
55
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invalid_blocks_and_matches_recx_check(capsys, monkeypatch, tmp_path):
|
|
59
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
60
|
+
code, out = stop(capsys, monkeypatch, event(tmp_path))
|
|
61
|
+
assert code == 0
|
|
62
|
+
data = json.loads(out)
|
|
63
|
+
assert data["decision"] == "block" and "recx:manifest" in data["reason"]
|
|
64
|
+
assert main(["check", str(tmp_path / "RECX.yaml")]) == 1
|
|
65
|
+
check_lines = capsys.readouterr().out.splitlines()
|
|
66
|
+
assert check_lines
|
|
67
|
+
for line in check_lines:
|
|
68
|
+
assert line.split(": ", 1)[1] in data["reason"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_manifest_at_git_toplevel_from_subdirectory(capsys, monkeypatch, tmp_path):
|
|
72
|
+
git_init(tmp_path)
|
|
73
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
74
|
+
sub = tmp_path / "a" / "b"
|
|
75
|
+
sub.mkdir(parents=True)
|
|
76
|
+
code, out = stop(capsys, monkeypatch, event(sub))
|
|
77
|
+
assert code == 0 and json.loads(out)["decision"] == "block"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_no_manifest_in_subdirectory_outside_git(capsys, monkeypatch, tmp_path):
|
|
81
|
+
sub = tmp_path / "sub"
|
|
82
|
+
sub.mkdir()
|
|
83
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
84
|
+
assert stop(capsys, monkeypatch, event(sub)) == (0, "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("how", ["active", "warn"])
|
|
88
|
+
def test_feedback_without_block(capsys, monkeypatch, tmp_path, how):
|
|
89
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
90
|
+
if how == "warn":
|
|
91
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "warn")
|
|
92
|
+
ev = event(tmp_path)
|
|
93
|
+
else:
|
|
94
|
+
ev = event(tmp_path, stop_hook_active=True)
|
|
95
|
+
code, out = stop(capsys, monkeypatch, ev)
|
|
96
|
+
data = json.loads(out)
|
|
97
|
+
assert code == 0 and "decision" not in data
|
|
98
|
+
spec = data["hookSpecificOutput"]
|
|
99
|
+
assert spec["hookEventName"] == "Stop" and "status" in spec["additionalContext"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_off_does_not_read_anything(capsys, monkeypatch, tmp_path):
|
|
103
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
104
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "off")
|
|
105
|
+
|
|
106
|
+
class Boom:
|
|
107
|
+
def read(self):
|
|
108
|
+
raise AssertionError("stdin read")
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(sys, "stdin", Boom())
|
|
111
|
+
monkeypatch.setattr(hook, "check_file", lambda *a, **k: pytest.fail("manifest read"))
|
|
112
|
+
assert main(["hook", "stop"]) == 0
|
|
113
|
+
assert capsys.readouterr().out == ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_unrecognised_value_behaves_as_block(capsys, monkeypatch, tmp_path):
|
|
117
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
118
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "sometimes")
|
|
119
|
+
_, out = stop(capsys, monkeypatch, event(tmp_path))
|
|
120
|
+
assert json.loads(out)["decision"] == "block"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize(
|
|
124
|
+
"stdin", ["", " \n", "{not json", "[1, 2]", "null", '{"hook_event_name": "Stop"}', '{"cwd": 5}']
|
|
125
|
+
)
|
|
126
|
+
def test_bad_input_never_blocks(capsys, monkeypatch, stdin):
|
|
127
|
+
assert stop(capsys, monkeypatch, stdin) == (0, "")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_internal_error_never_blocks(capsys, monkeypatch, tmp_path):
|
|
131
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
132
|
+
|
|
133
|
+
def boom(*a, **k):
|
|
134
|
+
raise RuntimeError("boom")
|
|
135
|
+
|
|
136
|
+
monkeypatch.setattr(hook, "check_file", boom)
|
|
137
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_unreadable_manifest_never_blocks(capsys, monkeypatch, tmp_path):
|
|
141
|
+
(tmp_path / "RECX.yaml").mkdir()
|
|
142
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# -- the entry script ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_script(stdin, env_extra=None, path=None):
|
|
149
|
+
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "RECX_STOP_HOOK")}
|
|
150
|
+
if path is not None:
|
|
151
|
+
env["PATH"] = path
|
|
152
|
+
env.update(env_extra or {})
|
|
153
|
+
return subprocess.run(["/bin/sh", str(SCRIPT)], input=stdin, capture_output=True, text=True, env=env)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_script_is_executable_in_git():
|
|
157
|
+
out = subprocess.run(
|
|
158
|
+
["git", "ls-files", "-s", "plugin/scripts/stop-hook.sh"], cwd=ROOT, capture_output=True, text=True
|
|
159
|
+
).stdout
|
|
160
|
+
assert out.startswith("100755")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_hooks_json_runs_the_script():
|
|
164
|
+
data = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
|
|
165
|
+
cmd = data["hooks"]["Stop"][0]["hooks"][0]["command"]
|
|
166
|
+
assert cmd == '"${CLAUDE_PLUGIN_ROOT}"/scripts/stop-hook.sh'
|
|
167
|
+
assert "matcher" not in data["hooks"]["Stop"][0]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_script_end_to_end_blocks_in_git_repo(tmp_path):
|
|
171
|
+
git_init(tmp_path)
|
|
172
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
173
|
+
sub = tmp_path / "src"
|
|
174
|
+
sub.mkdir()
|
|
175
|
+
recx_dir = Path(sys.executable).parent
|
|
176
|
+
assert (recx_dir / "recx").exists()
|
|
177
|
+
path = f"{recx_dir}{os.pathsep}{os.environ['PATH']}"
|
|
178
|
+
r = run_script(json.dumps(event(sub)), path=path)
|
|
179
|
+
assert r.returncode == 0, r.stderr
|
|
180
|
+
data = json.loads(r.stdout)
|
|
181
|
+
assert data["decision"] == "block" and "recx:manifest" in data["reason"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_script_off_exits_at_once(tmp_path):
|
|
185
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
186
|
+
r = run_script(json.dumps(event(tmp_path)), {"RECX_STOP_HOOK": "off"})
|
|
187
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_script_without_recx_or_uvx_is_silent(tmp_path):
|
|
191
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
192
|
+
bindir = tmp_path / "bin"
|
|
193
|
+
bindir.mkdir()
|
|
194
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
195
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_script_with_outdated_recx_falls_through(tmp_path):
|
|
199
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
200
|
+
bindir = tmp_path / "bin"
|
|
201
|
+
bindir.mkdir()
|
|
202
|
+
old = bindir / "recx"
|
|
203
|
+
old.write_text("#!/bin/sh\nexit 2\n")
|
|
204
|
+
old.chmod(0o755)
|
|
205
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
206
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_script_never_passes_a_failing_status_through(tmp_path):
|
|
210
|
+
# Claude Code reads exit status 2 from a Stop hook as a block; uv exits 2 on
|
|
211
|
+
# some failures, so a failing uvx must not become a blocked session.
|
|
212
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
213
|
+
bindir = tmp_path / "bin"
|
|
214
|
+
bindir.mkdir()
|
|
215
|
+
uvx = bindir / "uvx"
|
|
216
|
+
uvx.write_text("#!/bin/sh\necho 'error: offline' >&2\nexit 2\n")
|
|
217
|
+
uvx.chmod(0o755)
|
|
218
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
219
|
+
assert (r.returncode, r.stdout) == (0, "")
|
recx_cli-0.0.1/PKG-INFO
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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.
|
recx_cli-0.0.1/README.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# recx
|
|
2
|
-
|
|
3
|
-
*res extra commercium*: things outside commerce. A repository drops a `RECX.yaml` at its
|
|
4
|
-
root to tell other people's coding agents what it does, which of its modules can be taken on
|
|
5
|
-
their own, how to take them, and which checks anyone can rerun to see that they work.
|
|
6
|
-
|
|
7
|
-
The format is specified in [spec/SPEC.md](spec/SPEC.md); the schema is
|
|
8
|
-
[spec/recx.schema.json](spec/recx.schema.json).
|
|
9
|
-
|
|
10
|
-
## Checking a manifest
|
|
11
|
-
|
|
12
|
-
```sh
|
|
13
|
-
pip install recx-cli # or: uvx --from recx-cli recx check
|
|
14
|
-
|
|
15
|
-
recx check # ./RECX.yaml, including that module paths exist
|
|
16
|
-
recx check path/to/repo # a directory holding RECX.yaml
|
|
17
|
-
recx check --no-repo FILE # the file alone, without looking at the repository
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. Exit status 0
|
|
21
|
-
means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
|
|
22
|
-
check that the license file agrees with `license` (rule 4 of the spec).
|
|
23
|
-
|
|
24
|
-
The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
|
|
25
|
-
|
|
26
|
-
This README is an interim one. The full README is still to come.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.3}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|