recx-cli 0.0.1__tar.gz → 0.0.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.
Files changed (34) hide show
  1. {recx_cli-0.0.1 → recx_cli-0.0.2}/PKG-INFO +18 -1
  2. {recx_cli-0.0.1 → recx_cli-0.0.2}/README.md +17 -0
  3. {recx_cli-0.0.1 → recx_cli-0.0.2}/pyproject.toml +1 -1
  4. {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/check.py +5 -0
  5. {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/cli.py +19 -2
  6. recx_cli-0.0.2/src/recx_cli/hook.py +89 -0
  7. {recx_cli-0.0.1 → recx_cli-0.0.2}/tests/test_check.py +2 -2
  8. recx_cli-0.0.2/tests/test_hook.py +219 -0
  9. {recx_cli-0.0.1 → recx_cli-0.0.2}/.gitignore +0 -0
  10. {recx_cli-0.0.1 → recx_cli-0.0.2}/LICENSE +0 -0
  11. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/SPEC.md +0 -0
  12. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/evidence-without-run.yaml +0 -0
  13. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
  14. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/http-url.yaml +0 -0
  15. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/license-not-permissive.yaml +0 -0
  16. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/missing-capabilities.yaml +0 -0
  17. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/multiline-summary.yaml +0 -0
  18. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/package-without-purl.yaml +0 -0
  19. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
  20. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/unknown-key.yaml +0 -0
  21. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/unknown-version.yaml +0 -0
  22. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
  23. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
  24. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
  25. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
  26. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
  27. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
  28. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
  29. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
  30. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/valid/full.yaml +0 -0
  31. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/valid/minimal.yaml +0 -0
  32. {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/recx.schema.json +0 -0
  33. {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/__init__.py +0 -0
  34. {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/__main__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: recx-cli
3
- Version: 0.0.1
3
+ Version: 0.0.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
@@ -35,6 +35,23 @@ One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. E
35
35
  means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
36
36
  check that the license file agrees with `license` (rule 4 of the spec).
37
37
 
38
+ ## The Claude Code plugin
39
+
40
+ The `recx` plugin adds the `/recx:manifest` skill, for writing and maintaining a `RECX.yaml`,
41
+ and a Stop hook that keeps the manifest valid as a session ends.
42
+
43
+ ```
44
+ /plugin marketplace add jbmv/recx
45
+ /plugin install recx@recx
46
+ ```
47
+
48
+ On Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx` does both in one
49
+ step. The hook runs `recx hook stop`, from `recx` on your PATH or else through `uvx`; it needs
50
+ recx-cli 0.0.2 or later, and a missing `recx` never blocks a session. `RECX_STOP_HOOK` sets it:
51
+ `block` (the default), `warn` for feedback only, or `off`.
52
+
53
+ To adopt recx in a repository with any coding agent, see [ADOPT.md](ADOPT.md).
54
+
38
55
  The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
39
56
 
40
57
  This README is an interim one. The full README is still to come.
@@ -21,6 +21,23 @@ One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. E
21
21
  means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
22
22
  check that the license file agrees with `license` (rule 4 of the spec).
23
23
 
24
+ ## The Claude Code plugin
25
+
26
+ The `recx` plugin adds the `/recx:manifest` skill, for writing and maintaining a `RECX.yaml`,
27
+ and a Stop hook that keeps the manifest valid as a session ends.
28
+
29
+ ```
30
+ /plugin marketplace add jbmv/recx
31
+ /plugin install recx@recx
32
+ ```
33
+
34
+ On Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx` does both in one
35
+ step. The hook runs `recx hook stop`, from `recx` on your PATH or else through `uvx`; it needs
36
+ recx-cli 0.0.2 or later, and a missing `recx` never blocks a session. `RECX_STOP_HOOK` sets it:
37
+ `block` (the default), `warn` for feedback only, or `off`.
38
+
39
+ To adopt recx in a repository with any coding agent, see [ADOPT.md](ADOPT.md).
40
+
24
41
  The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
25
42
 
26
43
  This README is an interim one. The full README is still to come.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "recx-cli"
7
- version = "0.0.1"
7
+ version = "0.0.2"
8
8
  description = "Validator for RECX.yaml manifests: the recx check command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
 
@@ -7,7 +7,7 @@ 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
11
 
12
12
 
13
13
  def _version() -> str:
@@ -45,11 +45,28 @@ def build_parser() -> argparse.ArgumentParser:
45
45
  action="store_true",
46
46
  help="skip the checks that look at the repository (rule 3: module paths exist)",
47
47
  )
48
+ hook = sub.add_parser(
49
+ "hook",
50
+ help="entry points for Claude Code hooks",
51
+ description="Commands that Claude Code hooks run. They read the hook's JSON on stdin.",
52
+ )
53
+ hook_sub = hook.add_subparsers(dest="hook_command", required=True, parser_class=_Parser)
54
+ hook_sub.add_parser(
55
+ "stop",
56
+ help="Stop hook: keep the repository's RECX.yaml valid as a session ends",
57
+ description="Read Claude Code's Stop hook JSON on stdin. Always exits 0. "
58
+ "RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
59
+ "warn only gives feedback, off does nothing.",
60
+ )
48
61
  return parser
49
62
 
50
63
 
51
64
  def main(argv: list[str] | None = None) -> int:
52
65
  args = build_parser().parse_args(argv)
66
+ if args.command == "hook":
67
+ from recx_cli.hook import stop
68
+
69
+ return stop()
53
70
  target = Path(args.path)
54
71
  if target.is_dir():
55
72
  target = target / "RECX.yaml"
@@ -63,5 +80,5 @@ def main(argv: list[str] | None = None) -> int:
63
80
  print(f"{label}: valid")
64
81
  return 0
65
82
  for p in problems:
66
- print(f"{label}: {p.pointer}: {p.message}")
83
+ print(format_problem(label, p))
67
84
  return 1
@@ -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,8 @@ 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
- wheels = list(out.glob("recx_cli-0.0.1-*.whl"))
242
- sdists = list(out.glob("recx_cli-0.0.1.tar.gz"))
241
+ wheels = list(out.glob("recx_cli-0.0.2-*.whl"))
242
+ sdists = list(out.glob("recx_cli-0.0.2.tar.gz"))
243
243
  assert len(wheels) == 1 and len(sdists) == 1
244
244
  with zipfile.ZipFile(wheels[0]) as z:
245
245
  names = z.namelist()
@@ -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, "")
File without changes
File without changes
File without changes
File without changes