recx-cli 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {recx_cli-0.1.0 → recx_cli-0.1.2}/PKG-INFO +27 -6
- {recx_cli-0.1.0 → recx_cli-0.1.2}/README.md +24 -5
- {recx_cli-0.1.0 → recx_cli-0.1.2}/pyproject.toml +5 -2
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/cli.py +27 -3
- recx_cli-0.1.2/src/recx_cli/drift.py +312 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/hook.py +37 -9
- recx_cli-0.1.2/src/recx_cli/mcp_server.py +234 -0
- recx_cli-0.1.2/tests/test_drift.py +851 -0
- recx_cli-0.1.2/tests/test_mcp.py +170 -0
- recx_cli-0.1.2/tests/test_plugin_search.py +42 -0
- recx_cli-0.1.0/src/recx_cli/drift.py +0 -178
- recx_cli-0.1.0/tests/test_drift.py +0 -424
- {recx_cli-0.1.0 → recx_cli-0.1.2}/.gitignore +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/LICENSE +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/SPEC.md +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/evidence-without-run.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/http-url.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/license-not-permissive.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/missing-capabilities.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/multiline-summary.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/package-without-purl.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/unknown-key.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid/unknown-version.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/valid/full.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/examples/valid/minimal.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/index.schema.json +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/spec/recx.schema.json +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/__init__.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/__main__.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/check.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/evidence.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/index.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/index_cache.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/match.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/search-result.schema.json +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/src/recx_cli/search.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/anomr.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/argwise.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/confyaml.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/cronexpr.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/feedparse.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/feedwatch.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/kustomize-secrets.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/mcp-sqlite.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/mdhtml.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/mdlint.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/nextver.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/patient.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/pdfmine.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/pr-labeler.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/quickjson.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/schemacheck.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/sealbox.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/sitemapgen.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/slack-notify.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/steadyhttp.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/tablecheck.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/thumbsmith.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/tokencheck.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/tollgate.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/fixtures/search/yamlguard.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/search_cases.yaml +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_check.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_crawler_core.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_crawler_github.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_crawler_main.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_evidence.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_hook.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_index.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_index_cache.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_match.py +0 -0
- {recx_cli-0.1.0 → recx_cli-0.1.2}/tests/test_search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: recx-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Validator for RECX.yaml manifests: the recx check command.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jbmv/recx
|
|
6
6
|
Project-URL: Repository, https://github.com/jbmv/recx
|
|
@@ -10,6 +10,8 @@ License-File: LICENSE
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Requires-Dist: jsonschema[format-nongpl]>=4.18
|
|
12
12
|
Requires-Dist: pyyaml>=6
|
|
13
|
+
Provides-Extra: mcp
|
|
14
|
+
Requires-Dist: mcp<3,>=2.3; extra == 'mcp'
|
|
13
15
|
Description-Content-Type: text/markdown
|
|
14
16
|
|
|
15
17
|
# recx
|
|
@@ -75,6 +77,14 @@ anything listed can be reused without asking.
|
|
|
75
77
|
|
|
76
78
|
Then add the GitHub topic `recx` to the repository, so the daily index finds it promptly.
|
|
77
79
|
|
|
80
|
+
The plugin's hook runs when a session or one of its subagents stops (Claude Code's Stop and
|
|
81
|
+
SubagentStop events). It blocks on an invalid manifest, and it reminds, without blocking, when
|
|
82
|
+
files under a module's `path` changed during the session while RECX.yaml did not. A commit can
|
|
83
|
+
record that module entries were checked against its tree with a trailer such as
|
|
84
|
+
`Recx-Checked: lib, cli`; the reminder then disregards that commit's changes to those modules
|
|
85
|
+
and the changes of the session's commits it descends from. `RECX_DRIFT_REMINDER=off` turns
|
|
86
|
+
the reminder off. The [manifest skill](plugin/skills/manifest/SKILL.md) describes both in full.
|
|
87
|
+
|
|
78
88
|
## Searching
|
|
79
89
|
|
|
80
90
|
```sh
|
|
@@ -95,8 +105,19 @@ Every result is written by the repository's author and has been checked only aga
|
|
|
95
105
|
schema. The output says so in a fixed `notice`, and the JSON lists the author-written paths in
|
|
96
106
|
`untrusted`. The index never contains a manifest's `setup` or `run` commands. Before reusing
|
|
97
107
|
anything, read the repository's licence file and its current `RECX.yaml`, and run its evidence
|
|
98
|
-
only in a sandbox. [docs/
|
|
99
|
-
|
|
108
|
+
only in a sandbox. [docs/integrating.md](docs/integrating.md) explains how to call search from
|
|
109
|
+
another tool, and [docs/design/search.md](docs/design/search.md) describes the index, the crawler
|
|
110
|
+
and the ranking in full.
|
|
111
|
+
|
|
112
|
+
Agents can also search through an MCP server, `recx mcp`, which offers the same search as two
|
|
113
|
+
read-only tools, `recx_search` and `recx_show`. In Claude Code it comes with a second plugin,
|
|
114
|
+
`recx-search`, from the same marketplace: `/plugin install recx-search@recx`. The plugin is
|
|
115
|
+
separate so that sessions which only maintain a manifest do not start the server or carry its
|
|
116
|
+
tool definitions. It also adds the `recx-search:find` skill, which tells the agent when to
|
|
117
|
+
search while planning, how to read results, and how to record a reuse in its own manifest.
|
|
118
|
+
Other MCP clients can run the same server with their own configuration, for example
|
|
119
|
+
`claude mcp add recx -- uvx --from 'recx-cli[mcp]' recx mcp`. The server needs recx-cli 0.1.2
|
|
120
|
+
or later with the `mcp` extra.
|
|
100
121
|
|
|
101
122
|
## What's here
|
|
102
123
|
|
|
@@ -105,7 +126,8 @@ crawler and the ranking in full.
|
|
|
105
126
|
| [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
|
|
106
127
|
| `recx-cli` on PyPI | `recx check`, `recx evidence`, `recx search` and `recx show` (`pip install recx-cli`) |
|
|
107
128
|
| `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
|
|
108
|
-
| [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
|
|
129
|
+
| [plugin/](plugin/) | The `recx` Claude Code plugin; this repository is its marketplace |
|
|
130
|
+
| [plugin-search/](plugin-search/) | The opt-in `recx-search` plugin: the MCP server entry and the `recx-search:find` skill |
|
|
109
131
|
| [crawler/](crawler/) | The daily crawler that builds the search index, run by a scheduled workflow |
|
|
110
132
|
| [docs/design/](docs/design/) | Design documents |
|
|
111
133
|
| [RECX.yaml](RECX.yaml) | recx's own manifest |
|
|
@@ -115,8 +137,7 @@ crawler and the ranking in full.
|
|
|
115
137
|
Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
|
|
116
138
|
version it was written against. `recx check` enforces the whole spec except rule 4, that the
|
|
117
139
|
license file matches `license`; the search crawler applies it as far as GitHub's licence
|
|
118
|
-
detection allows. Search is in its first version: it ranks by keywords
|
|
119
|
-
and has no MCP server yet.
|
|
140
|
+
detection allows. Search is in its first version: it ranks by keywords and has no synonym handling.
|
|
120
141
|
|
|
121
142
|
## Kyosei
|
|
122
143
|
|
|
@@ -61,6 +61,14 @@ anything listed can be reused without asking.
|
|
|
61
61
|
|
|
62
62
|
Then add the GitHub topic `recx` to the repository, so the daily index finds it promptly.
|
|
63
63
|
|
|
64
|
+
The plugin's hook runs when a session or one of its subagents stops (Claude Code's Stop and
|
|
65
|
+
SubagentStop events). It blocks on an invalid manifest, and it reminds, without blocking, when
|
|
66
|
+
files under a module's `path` changed during the session while RECX.yaml did not. A commit can
|
|
67
|
+
record that module entries were checked against its tree with a trailer such as
|
|
68
|
+
`Recx-Checked: lib, cli`; the reminder then disregards that commit's changes to those modules
|
|
69
|
+
and the changes of the session's commits it descends from. `RECX_DRIFT_REMINDER=off` turns
|
|
70
|
+
the reminder off. The [manifest skill](plugin/skills/manifest/SKILL.md) describes both in full.
|
|
71
|
+
|
|
64
72
|
## Searching
|
|
65
73
|
|
|
66
74
|
```sh
|
|
@@ -81,8 +89,19 @@ Every result is written by the repository's author and has been checked only aga
|
|
|
81
89
|
schema. The output says so in a fixed `notice`, and the JSON lists the author-written paths in
|
|
82
90
|
`untrusted`. The index never contains a manifest's `setup` or `run` commands. Before reusing
|
|
83
91
|
anything, read the repository's licence file and its current `RECX.yaml`, and run its evidence
|
|
84
|
-
only in a sandbox. [docs/
|
|
85
|
-
|
|
92
|
+
only in a sandbox. [docs/integrating.md](docs/integrating.md) explains how to call search from
|
|
93
|
+
another tool, and [docs/design/search.md](docs/design/search.md) describes the index, the crawler
|
|
94
|
+
and the ranking in full.
|
|
95
|
+
|
|
96
|
+
Agents can also search through an MCP server, `recx mcp`, which offers the same search as two
|
|
97
|
+
read-only tools, `recx_search` and `recx_show`. In Claude Code it comes with a second plugin,
|
|
98
|
+
`recx-search`, from the same marketplace: `/plugin install recx-search@recx`. The plugin is
|
|
99
|
+
separate so that sessions which only maintain a manifest do not start the server or carry its
|
|
100
|
+
tool definitions. It also adds the `recx-search:find` skill, which tells the agent when to
|
|
101
|
+
search while planning, how to read results, and how to record a reuse in its own manifest.
|
|
102
|
+
Other MCP clients can run the same server with their own configuration, for example
|
|
103
|
+
`claude mcp add recx -- uvx --from 'recx-cli[mcp]' recx mcp`. The server needs recx-cli 0.1.2
|
|
104
|
+
or later with the `mcp` extra.
|
|
86
105
|
|
|
87
106
|
## What's here
|
|
88
107
|
|
|
@@ -91,7 +110,8 @@ crawler and the ranking in full.
|
|
|
91
110
|
| [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
|
|
92
111
|
| `recx-cli` on PyPI | `recx check`, `recx evidence`, `recx search` and `recx show` (`pip install recx-cli`) |
|
|
93
112
|
| `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
|
|
94
|
-
| [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
|
|
113
|
+
| [plugin/](plugin/) | The `recx` Claude Code plugin; this repository is its marketplace |
|
|
114
|
+
| [plugin-search/](plugin-search/) | The opt-in `recx-search` plugin: the MCP server entry and the `recx-search:find` skill |
|
|
95
115
|
| [crawler/](crawler/) | The daily crawler that builds the search index, run by a scheduled workflow |
|
|
96
116
|
| [docs/design/](docs/design/) | Design documents |
|
|
97
117
|
| [RECX.yaml](RECX.yaml) | recx's own manifest |
|
|
@@ -101,8 +121,7 @@ crawler and the ranking in full.
|
|
|
101
121
|
Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
|
|
102
122
|
version it was written against. `recx check` enforces the whole spec except rule 4, that the
|
|
103
123
|
license file matches `license`; the search crawler applies it as far as GitHub's licence
|
|
104
|
-
detection allows. Search is in its first version: it ranks by keywords
|
|
105
|
-
and has no MCP server yet.
|
|
124
|
+
detection allows. Search is in its first version: it ranks by keywords and has no synonym handling.
|
|
106
125
|
|
|
107
126
|
## Kyosei
|
|
108
127
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "recx-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Validator for RECX.yaml manifests: the recx check command."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -16,6 +16,9 @@ dependencies = [
|
|
|
16
16
|
"pyyaml>=6",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
mcp = ["mcp>=2.3,<3"]
|
|
21
|
+
|
|
19
22
|
[project.urls]
|
|
20
23
|
Homepage = "https://github.com/jbmv/recx"
|
|
21
24
|
Repository = "https://github.com/jbmv/recx"
|
|
@@ -24,7 +27,7 @@ Repository = "https://github.com/jbmv/recx"
|
|
|
24
27
|
recx = "recx_cli.cli:main"
|
|
25
28
|
|
|
26
29
|
[dependency-groups]
|
|
27
|
-
dev = ["pytest>=8"]
|
|
30
|
+
dev = ["pytest>=8", "mcp>=2.3,<3"]
|
|
28
31
|
|
|
29
32
|
[tool.hatch.build.targets.wheel]
|
|
30
33
|
packages = ["src/recx_cli"]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Command line: `recx check`, `recx evidence`, `recx search`, `recx show`, `recx hook stop`."""
|
|
1
|
+
"""Command line: `recx check`, `recx evidence`, `recx search`, `recx show`, `recx mcp`, `recx hook stop`."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -99,6 +99,21 @@ def _run_index_command(args) -> int:
|
|
|
99
99
|
return 0
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def _run_mcp() -> int:
|
|
103
|
+
from importlib.util import find_spec
|
|
104
|
+
|
|
105
|
+
if find_spec("mcp") is None:
|
|
106
|
+
print(
|
|
107
|
+
"recx mcp: the MCP server needs the optional 'mcp' extra, which is not installed.\n"
|
|
108
|
+
"Run it with: uvx --from 'recx-cli[mcp]' recx mcp",
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
)
|
|
111
|
+
return 2
|
|
112
|
+
from recx_cli.mcp_server import main as serve
|
|
113
|
+
|
|
114
|
+
return serve()
|
|
115
|
+
|
|
116
|
+
|
|
102
117
|
def build_parser() -> argparse.ArgumentParser:
|
|
103
118
|
parser = _Parser(prog="recx", description="Tools for RECX.yaml manifests.")
|
|
104
119
|
parser.add_argument("--version", action="version", version=f"recx {_version()}")
|
|
@@ -160,6 +175,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
160
175
|
sh.add_argument("full_name", metavar="OWNER/NAME")
|
|
161
176
|
sh.add_argument("--json", action="store_true", help="print the JSON object")
|
|
162
177
|
_index_options(sh)
|
|
178
|
+
sub.add_parser(
|
|
179
|
+
"mcp",
|
|
180
|
+
help="start the stdio MCP server (needs the recx-cli[mcp] extra)",
|
|
181
|
+
description="Serve recx_search and recx_show over stdio for MCP clients. Reads the index only. "
|
|
182
|
+
"Needs the optional extra: uvx --from 'recx-cli[mcp]' recx mcp. "
|
|
183
|
+
"Exit status: 0 on a clean shutdown, 2 when the extra is not installed.",
|
|
184
|
+
)
|
|
163
185
|
hook = sub.add_parser(
|
|
164
186
|
"hook",
|
|
165
187
|
help="entry points for Claude Code hooks",
|
|
@@ -168,8 +190,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
190
|
hook_sub = hook.add_subparsers(dest="hook_command", required=True, parser_class=_Parser)
|
|
169
191
|
hook_sub.add_parser(
|
|
170
192
|
"stop",
|
|
171
|
-
help="Stop hook: keep the repository's RECX.yaml valid
|
|
172
|
-
description="Read Claude Code's Stop hook JSON on stdin. Always exits 0. "
|
|
193
|
+
help="Stop and SubagentStop hook: keep the repository's RECX.yaml valid",
|
|
194
|
+
description="Read Claude Code's Stop or SubagentStop hook JSON on stdin. Always exits 0. "
|
|
173
195
|
"RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
|
|
174
196
|
"warn only gives feedback, off does nothing.",
|
|
175
197
|
)
|
|
@@ -192,6 +214,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
192
214
|
parser.error("show: expected OWNER/NAME, for example jbmv/recx")
|
|
193
215
|
if args.command in ("search", "show"):
|
|
194
216
|
return _run_index_command(args)
|
|
217
|
+
if args.command == "mcp":
|
|
218
|
+
return _run_mcp()
|
|
195
219
|
if args.command == "hook":
|
|
196
220
|
from recx_cli.hook import session_start, stop
|
|
197
221
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Drift reminder: a manifest can be valid and stale.
|
|
2
|
+
|
|
3
|
+
`recx hook session-start` records the repository's HEAD for the session.
|
|
4
|
+
`recx hook stop` (run on Stop and on SubagentStop) then asks which modules have
|
|
5
|
+
changed files since, and, when RECX.yaml is not among them, reminds once per set
|
|
6
|
+
of modules. A commit whose message carries the trailer
|
|
7
|
+
`Recx-Checked: <module-id>[, <module-id>...]` records that those modules' entries
|
|
8
|
+
were checked against the tree at that commit, so its changes to them, and those
|
|
9
|
+
of its ancestors in the session's range, do not count. The reminder is only ever
|
|
10
|
+
feedback, never a block. Callers swallow every exception raised here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from recx_cli.check import parse
|
|
24
|
+
|
|
25
|
+
MANIFEST = "RECX.yaml"
|
|
26
|
+
SKILL = "recx:manifest"
|
|
27
|
+
_HEAD = re.compile(r"[0-9a-fA-F]{7,64}")
|
|
28
|
+
_SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def enabled() -> bool:
|
|
32
|
+
return os.environ.get("RECX_DRIFT_REMINDER", "").strip().lower() != "off"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def state_dir() -> Path:
|
|
36
|
+
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
37
|
+
if plugin_data:
|
|
38
|
+
return Path(plugin_data)
|
|
39
|
+
base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
|
|
40
|
+
return Path(base) / "recx"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _state_file(session_id: str) -> Path | None:
|
|
44
|
+
if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id):
|
|
45
|
+
return None
|
|
46
|
+
return state_dir() / f"{session_id}.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_state(session_id: str) -> dict:
|
|
50
|
+
"""The session's state, or {} when absent, unreadable or corrupt."""
|
|
51
|
+
try:
|
|
52
|
+
path = _state_file(session_id)
|
|
53
|
+
if path is None:
|
|
54
|
+
return {}
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
return {}
|
|
58
|
+
state: dict = {}
|
|
59
|
+
head = data.get("head")
|
|
60
|
+
if isinstance(head, str) and _HEAD.fullmatch(head):
|
|
61
|
+
state["head"] = head
|
|
62
|
+
reminded = data.get("reminded")
|
|
63
|
+
if isinstance(reminded, list):
|
|
64
|
+
state["reminded"] = [m for m in reminded if isinstance(m, str)]
|
|
65
|
+
agents = data.get("agents")
|
|
66
|
+
if isinstance(agents, dict):
|
|
67
|
+
state["agents"] = {
|
|
68
|
+
a: [m for m in r if isinstance(m, str)]
|
|
69
|
+
for a, r in agents.items()
|
|
70
|
+
if isinstance(a, str) and _SAFE_ID.fullmatch(a) and isinstance(r, list)
|
|
71
|
+
}
|
|
72
|
+
return state
|
|
73
|
+
except Exception: # noqa: BLE001 - a bad state file is ignored
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_state(session_id: str, state: dict) -> None:
|
|
78
|
+
path = _state_file(session_id)
|
|
79
|
+
if path is None:
|
|
80
|
+
return
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
|
|
83
|
+
try:
|
|
84
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
85
|
+
json.dump(state, fh)
|
|
86
|
+
os.replace(tmp, path)
|
|
87
|
+
except BaseException:
|
|
88
|
+
try:
|
|
89
|
+
os.unlink(tmp)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _git(top: Path, *args: str) -> str:
|
|
96
|
+
out = subprocess.run(
|
|
97
|
+
["git", "-C", str(top), *args], capture_output=True, text=True, timeout=20, check=True
|
|
98
|
+
)
|
|
99
|
+
return out.stdout
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def head_of(top: Path) -> str | None:
|
|
103
|
+
value = _git(top, "rev-parse", "HEAD").strip()
|
|
104
|
+
return value if _HEAD.fullmatch(value) else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
STATE_MAX_AGE_DAYS = 30
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def prune_state(max_age_days: int = STATE_MAX_AGE_DAYS) -> None:
|
|
111
|
+
"""Delete session state files older than max_age_days; one is written per session."""
|
|
112
|
+
try:
|
|
113
|
+
cutoff = time.time() - max_age_days * 86400
|
|
114
|
+
for path in state_dir().glob("*.json"):
|
|
115
|
+
try:
|
|
116
|
+
if path.stat().st_mtime < cutoff:
|
|
117
|
+
path.unlink()
|
|
118
|
+
except OSError:
|
|
119
|
+
continue
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def session_start(session_id: str, top: Path) -> None:
|
|
125
|
+
"""Record HEAD for the session. Skips when there is no manifest or the reminder is off."""
|
|
126
|
+
if not enabled() or not (top / MANIFEST).is_file():
|
|
127
|
+
return
|
|
128
|
+
prune_state()
|
|
129
|
+
head = head_of(top)
|
|
130
|
+
if head:
|
|
131
|
+
write_state(session_id, {"head": head, "reminded": []})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_TRAILER_LINE = re.compile(r"\s*recx-checked\s*:(.*)", re.IGNORECASE)
|
|
135
|
+
_ID_SEP = re.compile(r"[,\s]+")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _uncommitted(top: Path) -> set[str]:
|
|
139
|
+
"""Files changed against HEAD in the index or working tree, plus untracked files."""
|
|
140
|
+
files = set(_git(top, "diff", "--name-only", "-z", "HEAD", "--").split("\0"))
|
|
141
|
+
files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
|
|
142
|
+
files.discard("")
|
|
143
|
+
return files
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def changed_files(top: Path, head: str | None) -> set[str]:
|
|
147
|
+
"""The net change since head (or HEAD) plus untracked files: the reading without trailers."""
|
|
148
|
+
files: set[str] = set()
|
|
149
|
+
for base in [head, "HEAD"] if head else ["HEAD"]:
|
|
150
|
+
try:
|
|
151
|
+
files.update(_git(top, "diff", "--name-only", "-z", base, "--").split("\0"))
|
|
152
|
+
break
|
|
153
|
+
except (OSError, subprocess.SubprocessError):
|
|
154
|
+
continue
|
|
155
|
+
files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
|
|
156
|
+
files.discard("")
|
|
157
|
+
return files
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def checked_ids(trailers: str) -> set[str]:
|
|
161
|
+
"""Module ids named by Recx-Checked trailers; anything malformed names nothing."""
|
|
162
|
+
ids: set[str] = set()
|
|
163
|
+
for line in trailers.splitlines():
|
|
164
|
+
m = _TRAILER_LINE.fullmatch(line)
|
|
165
|
+
if m:
|
|
166
|
+
ids.update(i for i in _ID_SEP.split(m.group(1)) if i)
|
|
167
|
+
return ids
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Commit:
|
|
171
|
+
__slots__ = ("sha", "parents", "checked", "files")
|
|
172
|
+
|
|
173
|
+
def __init__(self, sha: str, parents: list[str], checked: set[str], files: set[str]) -> None:
|
|
174
|
+
self.sha, self.parents, self.checked, self.files = sha, parents, checked, files
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def range_commits(top: Path, head: str) -> list[Commit]:
|
|
178
|
+
"""Every commit in head..HEAD with its parents, Recx-Checked ids and touched files.
|
|
179
|
+
|
|
180
|
+
Two `git log` calls: one for the graph and the files, which holds no free text,
|
|
181
|
+
and one for the trailers, NUL-separated, since a commit message cannot hold a
|
|
182
|
+
NUL. Merge commits list no files of their own; the commits they bring in are in
|
|
183
|
+
the range themselves. Raises when git fails (unknown head, shallow history, no
|
|
184
|
+
git); a record that does not parse is skipped.
|
|
185
|
+
"""
|
|
186
|
+
rng = f"{head}..HEAD"
|
|
187
|
+
graph = _git(
|
|
188
|
+
top, "log", "-z", "--no-renames", "--no-ext-diff", "--format=%x1e%H %P%x1f", "--name-only", rng, "--"
|
|
189
|
+
)
|
|
190
|
+
trailers = _git(top, "log", "-z", "--format=%H%n%(trailers:only,unfold)", rng, "--")
|
|
191
|
+
checked: dict[str, set[str]] = {}
|
|
192
|
+
for record in trailers.split("\0"):
|
|
193
|
+
sha, _, text = record.partition("\n")
|
|
194
|
+
if _HEAD.fullmatch(sha):
|
|
195
|
+
checked[sha] = checked_ids(text)
|
|
196
|
+
commits: list[Commit] = []
|
|
197
|
+
for record in graph.split("\x1e"):
|
|
198
|
+
header, sep, rest = record.partition("\x1f")
|
|
199
|
+
shas = header.split()
|
|
200
|
+
if not sep or not shas or not all(_HEAD.fullmatch(x) for x in shas):
|
|
201
|
+
continue
|
|
202
|
+
rest = rest.lstrip("\0")
|
|
203
|
+
if rest.startswith("\n"):
|
|
204
|
+
rest = rest[1:]
|
|
205
|
+
files = {f for f in rest.split("\0") if f}
|
|
206
|
+
commits.append(Commit(shas[0], shas[1:], checked.get(shas[0], set()), files))
|
|
207
|
+
return commits
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _covered(commits: list[Commit], module_id: str) -> set[str]:
|
|
211
|
+
"""Commits in the range that a Recx-Checked trailer naming module_id vouches for.
|
|
212
|
+
|
|
213
|
+
A check vouches for the tree it was made on, so a checking commit covers itself
|
|
214
|
+
and every ancestor of it within the range. Every path from a commit in the range
|
|
215
|
+
to an ancestor in the range stays inside the range, so walking parents that are
|
|
216
|
+
in the range finds exactly those ancestors.
|
|
217
|
+
"""
|
|
218
|
+
by_sha = {c.sha: c for c in commits}
|
|
219
|
+
covered: set[str] = set()
|
|
220
|
+
stack = [c.sha for c in commits if module_id in c.checked]
|
|
221
|
+
while stack:
|
|
222
|
+
sha = stack.pop()
|
|
223
|
+
if sha in covered:
|
|
224
|
+
continue
|
|
225
|
+
covered.add(sha)
|
|
226
|
+
stack.extend(p for p in by_sha[sha].parents if p in by_sha and p not in covered)
|
|
227
|
+
return covered
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _modules(manifest: Path) -> list[tuple[str, str]]:
|
|
231
|
+
data = parse(manifest.read_text(encoding="utf-8"))
|
|
232
|
+
modules = data.get("modules") if isinstance(data, dict) else None
|
|
233
|
+
found: list[tuple[str, str]] = []
|
|
234
|
+
for mod in modules if isinstance(modules, list) else []:
|
|
235
|
+
if not isinstance(mod, dict):
|
|
236
|
+
continue
|
|
237
|
+
mid, path = mod.get("id"), mod.get("path")
|
|
238
|
+
if isinstance(mid, str) and isinstance(path, str):
|
|
239
|
+
found.append((mid, path))
|
|
240
|
+
return found
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _touches(path: str, files: set[str]) -> bool:
|
|
244
|
+
if path.endswith("/"):
|
|
245
|
+
return any(f.startswith(path) for f in files)
|
|
246
|
+
return path in files
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def affected_modules(manifest: Path, files: set[str], commits: list[Commit] | None = None) -> list[str]:
|
|
250
|
+
"""Modules whose path is touched by files, or by a commit no trailer for them covers."""
|
|
251
|
+
found: list[str] = []
|
|
252
|
+
for mid, path in _modules(manifest):
|
|
253
|
+
if mid in found:
|
|
254
|
+
continue
|
|
255
|
+
hit = _touches(path, files)
|
|
256
|
+
if not hit and commits:
|
|
257
|
+
covered = _covered(commits, mid)
|
|
258
|
+
hit = any(c.sha not in covered and _touches(path, c.files) for c in commits)
|
|
259
|
+
if hit:
|
|
260
|
+
found.append(mid)
|
|
261
|
+
return found
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _changes(top: Path, head: str | None) -> tuple[set[str], list[Commit] | None]:
|
|
265
|
+
"""(files changed outside any commit's trailer, the session's commits).
|
|
266
|
+
|
|
267
|
+
With a recorded head whose range git can read, the files are the uncommitted
|
|
268
|
+
and untracked ones and the commits are read one by one. Otherwise (no head,
|
|
269
|
+
or any failure reading the range) this is the reading without trailers: the
|
|
270
|
+
net change since head, or the uncommitted change alone, with no commits.
|
|
271
|
+
"""
|
|
272
|
+
if head:
|
|
273
|
+
try:
|
|
274
|
+
return _uncommitted(top), range_commits(top, head)
|
|
275
|
+
except Exception: # noqa: BLE001 - reading commits or trailers never costs the reminder
|
|
276
|
+
pass
|
|
277
|
+
return changed_files(top, head), None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def reminder(session_id: str | None, top: Path, agent_id: str | None = None) -> str | None:
|
|
281
|
+
"""The reminder text to emit now, or None. Records what it reminded about.
|
|
282
|
+
|
|
283
|
+
The session and each subagent (by agent_id) keep their own record of what they
|
|
284
|
+
were reminded about, so a subagent that is reminded never silences the session.
|
|
285
|
+
"""
|
|
286
|
+
if not enabled():
|
|
287
|
+
return None
|
|
288
|
+
state = read_state(session_id) if session_id else {}
|
|
289
|
+
files, commits = _changes(top, state.get("head"))
|
|
290
|
+
if MANIFEST in files or any(MANIFEST in c.files for c in commits or []):
|
|
291
|
+
return None
|
|
292
|
+
affected = affected_modules(top / MANIFEST, files, commits)
|
|
293
|
+
if agent_id is not None and not _SAFE_ID.fullmatch(agent_id):
|
|
294
|
+
agent_id = None
|
|
295
|
+
agents = state.get("agents", {})
|
|
296
|
+
already = set(agents.get(agent_id, []) if agent_id else state.get("reminded", []))
|
|
297
|
+
if not affected or set(affected) <= already:
|
|
298
|
+
return None
|
|
299
|
+
if session_id:
|
|
300
|
+
seen = sorted(already | set(affected))
|
|
301
|
+
update = {"agents": {**agents, agent_id: seen}} if agent_id else {"reminded": seen}
|
|
302
|
+
try:
|
|
303
|
+
write_state(session_id, {**state, **update})
|
|
304
|
+
except Exception: # noqa: BLE001 - failing to record must not lose the reminder
|
|
305
|
+
pass
|
|
306
|
+
names = ", ".join(affected)
|
|
307
|
+
return (
|
|
308
|
+
f"Reminder, not an error: files under these {MANIFEST} modules changed this session "
|
|
309
|
+
f"while {MANIFEST} did not: {names}. Check whether the manifest still describes them, "
|
|
310
|
+
f"using the {SKILL} skill, and update it if it does not. If it is still accurate, "
|
|
311
|
+
f"nothing needs doing."
|
|
312
|
+
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""`recx hook stop`: Claude Code's Stop hook, keeping a RECX.yaml valid
|
|
1
|
+
"""`recx hook stop`: Claude Code's Stop and SubagentStop hook, keeping a RECX.yaml valid.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The same entry point serves both events; the event's own `hook_event_name` is
|
|
4
|
+
named back in any `hookSpecificOutput`. The hook must never trap a session on
|
|
5
|
+
its own failure, so every error path exits 0 without output. Output is always
|
|
6
|
+
JSON on stdout with exit status 0.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -18,6 +19,7 @@ from recx_cli.check import Unreadable, check_file, format_problem
|
|
|
18
19
|
|
|
19
20
|
MANIFEST = "RECX.yaml"
|
|
20
21
|
SKILL = "recx:manifest"
|
|
22
|
+
EVENTS = ("Stop", "SubagentStop")
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
def _mode() -> str:
|
|
@@ -55,6 +57,27 @@ def _read_event() -> tuple[dict, Path] | None:
|
|
|
55
57
|
return event, _manifest_dir(cwd)
|
|
56
58
|
|
|
57
59
|
|
|
60
|
+
def _event_name(event: dict) -> str:
|
|
61
|
+
name = event.get("hook_event_name")
|
|
62
|
+
return name if name in EVENTS else "Stop"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _internal_agent(event: dict) -> bool:
|
|
66
|
+
"""A SubagentStop from one of Claude Code's own agents (prompt suggestions, /btw).
|
|
67
|
+
|
|
68
|
+
Claude Code documents an empty `agent_type` for those when the session runs
|
|
69
|
+
without --agent. Such an agent did not do the session's work and cannot act on
|
|
70
|
+
feedback, so neither the check nor the reminder is spent on it.
|
|
71
|
+
"""
|
|
72
|
+
return _event_name(event) == "SubagentStop" and not event.get("agent_type")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _context(event: dict, text: str) -> str:
|
|
76
|
+
return json.dumps(
|
|
77
|
+
{"hookSpecificOutput": {"hookEventName": _event_name(event), "additionalContext": text}}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
58
81
|
def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
|
|
59
82
|
"""(manifest is valid, output to print). Knows nothing about drift."""
|
|
60
83
|
manifest = top / MANIFEST
|
|
@@ -75,20 +98,23 @@ def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
|
|
|
75
98
|
return False, json.dumps({"decision": "block", "reason": reason})
|
|
76
99
|
still = " still" if event.get("stop_hook_active") is True else ""
|
|
77
100
|
context = f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
|
|
78
|
-
return False,
|
|
79
|
-
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
|
|
80
|
-
)
|
|
101
|
+
return False, _context(event, context)
|
|
81
102
|
|
|
82
103
|
|
|
83
104
|
def _drift(event: dict, top: Path) -> str | None:
|
|
84
105
|
try:
|
|
85
106
|
session = event.get("session_id")
|
|
86
|
-
|
|
107
|
+
agent = event.get("agent_id") if _event_name(event) == "SubagentStop" else None
|
|
108
|
+
text = drift.reminder(
|
|
109
|
+
session if isinstance(session, str) else None,
|
|
110
|
+
top,
|
|
111
|
+
agent if isinstance(agent, str) else None,
|
|
112
|
+
)
|
|
87
113
|
except BaseException: # noqa: BLE001 - the drift path must never block or raise
|
|
88
114
|
return None
|
|
89
115
|
if text is None:
|
|
90
116
|
return None
|
|
91
|
-
return
|
|
117
|
+
return _context(event, text)
|
|
92
118
|
|
|
93
119
|
|
|
94
120
|
def _run() -> None:
|
|
@@ -98,6 +124,8 @@ def _run() -> None:
|
|
|
98
124
|
if parsed is None:
|
|
99
125
|
return
|
|
100
126
|
event, top = parsed
|
|
127
|
+
if _internal_agent(event):
|
|
128
|
+
return
|
|
101
129
|
valid, output = _validity(event, top)
|
|
102
130
|
if output is not None:
|
|
103
131
|
print(output)
|