cuff-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cuff/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Cuff's host-neutral claim and evidence gate."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
cuff/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from cuff.cli import main
2
+
3
+
4
+ raise SystemExit(main())
cuff/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """Cuff's intentionally small command line surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from .errors import CuffError
13
+ from .gate import check
14
+ from .ledger import append, create_claim, seal, verify
15
+ from .subject import declared_subject, filesystem_subject
16
+ from .workspace import find_workspace, initialize_workspace
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(prog="cuff")
21
+ parser.add_argument("--version", action="version", version=__version__)
22
+ commands = parser.add_subparsers(dest="command", required=True)
23
+
24
+ initialize = commands.add_parser("init", help="initialize a Cuff workspace")
25
+ initialize.add_argument("--workspace", type=Path)
26
+ initialize.add_argument("--json", action="store_true")
27
+
28
+ claim = commands.add_parser("claim", help="record a completion claim")
29
+ claim.add_argument("--workspace", type=Path)
30
+ claim.add_argument("--work-item", required=True)
31
+ claim.add_argument("--summary", required=True)
32
+ _add_subject_arguments(claim)
33
+ claim.add_argument("--actor")
34
+ claim.add_argument("--json", action="store_true")
35
+
36
+ verification = commands.add_parser("verify", help="run a command and record its result")
37
+ verification.add_argument("--workspace", type=Path)
38
+ verification.add_argument("--work-item", required=True)
39
+ verification.add_argument("--claim", required=True)
40
+ verification.add_argument("--timeout", type=float, default=300)
41
+ verification.add_argument("--actor")
42
+ verification.add_argument("--json", action="store_true")
43
+ verification.add_argument("verification_command", nargs=argparse.REMAINDER)
44
+
45
+ sealing = commands.add_parser("seal", help="atomically record a claim and executed evidence")
46
+ sealing.add_argument("--workspace", type=Path)
47
+ sealing.add_argument("--work-item", required=True)
48
+ sealing.add_argument("--summary", required=True)
49
+ _add_subject_arguments(sealing)
50
+ sealing.add_argument("--timeout", type=float, default=300)
51
+ sealing.add_argument("--actor")
52
+ sealing.add_argument("--json", action="store_true")
53
+ sealing.add_argument("verification_command", nargs=argparse.REMAINDER)
54
+
55
+ readiness = commands.add_parser("check", help="require fresh evidence for the latest claim")
56
+ readiness.add_argument("--workspace", type=Path)
57
+ readiness.add_argument("--work-item", required=True)
58
+ readiness.add_argument("--base")
59
+ readiness.add_argument("--head")
60
+ readiness.add_argument("--json", action="store_true")
61
+ return parser
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = build_parser()
66
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
67
+ try:
68
+ if args.command == "init":
69
+ data = initialize_workspace(args.workspace)
70
+ return _finish(args, data, f"Cuff project: {data['status']}")
71
+
72
+ root = find_workspace(args.workspace)
73
+ if args.command == "claim":
74
+ subject = _subject(root, args)
75
+ record = create_claim(root, args.work_item, args.summary, subject, args.actor)
76
+ path, line = append(root, record)
77
+ return _finish(
78
+ args,
79
+ {"ok": True, "record": record, "path": str(path), "line": line},
80
+ f"Claim {record['id']} recorded for {record['work_item']}",
81
+ )
82
+ if args.command == "verify":
83
+ observed = verify(
84
+ root,
85
+ args.work_item,
86
+ args.claim,
87
+ _verification_command(args.verification_command),
88
+ timeout=args.timeout,
89
+ actor=args.actor,
90
+ )
91
+ data = {
92
+ "ok": observed.record["exit_code"] == 0,
93
+ "record": observed.record,
94
+ "path": str(observed.path),
95
+ "line": observed.line,
96
+ "timed_out": observed.timed_out,
97
+ }
98
+ return _finish_observation(
99
+ args,
100
+ data,
101
+ observed.stdout,
102
+ observed.stderr,
103
+ f"Evidence {observed.record['id']} recorded: exit {observed.record['exit_code']}",
104
+ )
105
+ if args.command == "seal":
106
+ sealed = seal(
107
+ root,
108
+ args.work_item,
109
+ args.summary,
110
+ _subject(root, args),
111
+ _verification_command(args.verification_command),
112
+ timeout=args.timeout,
113
+ actor=args.actor,
114
+ )
115
+ data = {
116
+ "ok": sealed.evidence["exit_code"] == 0,
117
+ "claim": sealed.claim,
118
+ "evidence": sealed.evidence,
119
+ "path": str(sealed.path),
120
+ "lines": sealed.lines,
121
+ "timed_out": sealed.timed_out,
122
+ }
123
+ return _finish_observation(
124
+ args,
125
+ data,
126
+ sealed.stdout,
127
+ sealed.stderr,
128
+ f"Claim {sealed.claim['id']} and evidence {sealed.evidence['id']} recorded: "
129
+ f"exit {sealed.evidence['exit_code']}",
130
+ )
131
+ if args.command == "check":
132
+ data = check(root, args.work_item, base=args.base, head=args.head)
133
+ return _finish_result(args, data, "Cuff readiness")
134
+ parser.error("unknown command")
135
+ except CuffError as exc:
136
+ return _finish_error(args, exc)
137
+ except KeyboardInterrupt:
138
+ return 130
139
+ except Exception as exc:
140
+ if getattr(args, "json", False):
141
+ print(json.dumps({
142
+ "ok": False,
143
+ "errors": [{"code": "CUFF_UNEXPECTED", "message": f"{type(exc).__name__}: {exc}"}],
144
+ }, sort_keys=True, indent=2))
145
+ else:
146
+ print(f"CUFF_UNEXPECTED: {type(exc).__name__}: {exc}", file=sys.stderr)
147
+ return 3
148
+ return 2
149
+
150
+
151
+ def _add_subject_arguments(parser: argparse.ArgumentParser) -> None:
152
+ parser.add_argument("--subject-kind")
153
+ parser.add_argument("--subject-ref")
154
+ parser.add_argument("--subject-digest")
155
+ parser.add_argument("--subject-path", type=Path)
156
+
157
+
158
+ def _subject(root: Path, args: argparse.Namespace) -> dict[str, str]:
159
+ declared = (args.subject_kind, args.subject_ref, args.subject_digest)
160
+ if args.subject_path is not None:
161
+ if any(value is not None for value in declared):
162
+ raise CuffError(
163
+ "CUFF_SUBJECT_INVALID",
164
+ "Choose either --subject-path or all declared subject arguments",
165
+ )
166
+ return filesystem_subject(root, args.subject_path)
167
+ if any(value is None for value in declared):
168
+ raise CuffError(
169
+ "CUFF_SUBJECT_REQUIRED",
170
+ "Pass --subject-path or --subject-kind, --subject-ref, and --subject-digest",
171
+ )
172
+ return declared_subject(*declared)
173
+
174
+
175
+ def _verification_command(command: list[str]) -> list[str]:
176
+ return command[1:] if command[:1] == ["--"] else command
177
+
178
+
179
+ def _finish(args: argparse.Namespace, data: dict[str, Any], message: str) -> int:
180
+ if args.json:
181
+ print(json.dumps(data, sort_keys=True, indent=2))
182
+ else:
183
+ print(message)
184
+ return 0
185
+
186
+
187
+ def _finish_observation(
188
+ args: argparse.Namespace,
189
+ data: dict[str, Any],
190
+ stdout: bytes,
191
+ stderr: bytes,
192
+ message: str,
193
+ ) -> int:
194
+ if args.json:
195
+ print(json.dumps(data, sort_keys=True, indent=2))
196
+ else:
197
+ _replay(stdout, sys.stdout)
198
+ _replay(stderr, sys.stderr)
199
+ print(message)
200
+ return 0 if data["ok"] else 1
201
+
202
+
203
+ def _finish_result(args: argparse.Namespace, data: dict[str, Any], label: str) -> int:
204
+ if args.json:
205
+ print(json.dumps(data, sort_keys=True, indent=2))
206
+ else:
207
+ print(f"{label}: {'PASS' if data['ok'] else 'FAIL'}")
208
+ for error in data["errors"]:
209
+ print(f"ERROR {error['code']}: {error['message']}")
210
+ return 0 if data["ok"] else 1
211
+
212
+
213
+ def _finish_error(args: argparse.Namespace, error: CuffError) -> int:
214
+ data = {"ok": False, "errors": [error.to_dict()]}
215
+ if getattr(args, "json", False):
216
+ print(json.dumps(data, sort_keys=True, indent=2))
217
+ else:
218
+ print(str(error), file=sys.stderr)
219
+ return 1
220
+
221
+
222
+ def _replay(content: bytes, stream: Any) -> None:
223
+ if content:
224
+ stream.write(content.decode(errors="replace"))
225
+ if not content.endswith(b"\n"):
226
+ stream.write("\n")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
cuff/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Small, stable failure shapes shared by the CLI and gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class CuffError(Exception):
11
+ code: str
12
+ message: str
13
+ context: dict[str, Any] = field(default_factory=dict)
14
+
15
+ def __str__(self) -> str:
16
+ return f"{self.code}: {self.message}"
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ return {"code": self.code, "message": self.message, **self.context}
cuff/gate.py ADDED
@@ -0,0 +1,206 @@
1
+ """The single latest-claim readiness decision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .errors import CuffError
9
+ from .ledger import RECORDS_DIR, normalize_work_item, read, read_all
10
+ from .subject import current_subject
11
+
12
+
13
+ def check(
14
+ root: Path,
15
+ work_item: str,
16
+ *,
17
+ base: str | None = None,
18
+ head: str | None = None,
19
+ ) -> dict[str, Any]:
20
+ normalized = normalize_work_item(work_item)
21
+ result: dict[str, Any] = {
22
+ "ok": False,
23
+ "errors": [],
24
+ "work_item": normalized,
25
+ "latest_claim": None,
26
+ "selected_evidence": None,
27
+ "record_count": 0,
28
+ }
29
+ try:
30
+ read_all(root)
31
+ records = read(root, normalized)
32
+ except CuffError as exc:
33
+ result["errors"].append(exc.to_dict())
34
+ return result
35
+
36
+ result["record_count"] = len(records)
37
+ claims = [record for record in records if record["type"] == "claim"]
38
+ claim = claims[-1] if claims else None
39
+ result["latest_claim"] = claim
40
+
41
+ git_context = _check_git_workspace(root, base, head, result["errors"])
42
+ if claim is None:
43
+ _fail(result["errors"], "CUFF_CLAIM_MISSING", "No completion claim exists", work_item=normalized)
44
+ return result
45
+
46
+ passing = [
47
+ record
48
+ for record in records
49
+ if record["type"] == "evidence"
50
+ and record["claim"] == claim["id"]
51
+ and record["exit_code"] == 0
52
+ ]
53
+ for evidence in reversed(passing):
54
+ if not _subject_fresh(root, claim):
55
+ break
56
+ if git_context is None or not _evidence_fresh(root, evidence, head, git_context):
57
+ continue
58
+ result["selected_evidence"] = evidence
59
+ result["ok"] = not result["errors"]
60
+ return result
61
+
62
+ _fail(
63
+ result["errors"],
64
+ "CUFF_EVIDENCE_MISSING",
65
+ "Latest claim has no fresh passing evidence",
66
+ work_item=normalized,
67
+ record_id=claim["id"],
68
+ )
69
+ return result
70
+
71
+
72
+ def _subject_fresh(root: Path, claim: dict[str, Any]) -> bool:
73
+ try:
74
+ return current_subject(root, claim["subject"]) == claim["subject"]
75
+ except CuffError:
76
+ return False
77
+
78
+
79
+ def _check_git_workspace(
80
+ root: Path,
81
+ base: str | None,
82
+ head: str | None,
83
+ errors: list[dict[str, Any]],
84
+ ) -> tuple[Path, str] | None:
85
+ from . import git
86
+
87
+ try:
88
+ workspace = root.resolve()
89
+ repository = git.repo_root(workspace)
90
+ if repository != workspace:
91
+ raise CuffError(
92
+ "CUFF_WORKSPACE_NOT_ROOT",
93
+ "Cuff workspace must be the Git worktree root",
94
+ {"workspace": str(workspace), "repository": str(repository)},
95
+ )
96
+ dirty = [
97
+ path for path in git.dirty_paths(repository)
98
+ if not _is_record_path(repository, workspace, path)
99
+ ]
100
+ if dirty:
101
+ _fail(
102
+ errors,
103
+ "CUFF_REPOSITORY_DIRTY",
104
+ "Git readiness cannot evaluate uncommitted non-ledger changes",
105
+ paths=dirty,
106
+ )
107
+
108
+ proposed_ref = head or "HEAD"
109
+ proposed = git.head(repository, proposed_ref)
110
+ comparison_base = _comparison_base(repository, base, proposed_ref)
111
+ if comparison_base is not None:
112
+ if not git.is_ancestor(comparison_base, proposed, repository):
113
+ _fail(
114
+ errors,
115
+ "CUFF_GIT_ANCESTRY",
116
+ "The comparison base is not an ancestor of the selected head",
117
+ base=comparison_base,
118
+ head=proposed,
119
+ )
120
+ _check_append_only(repository, workspace, comparison_base, head, errors)
121
+ return repository, proposed
122
+ except CuffError as exc:
123
+ errors.append(exc.to_dict())
124
+ return None
125
+
126
+
127
+ def _comparison_base(repository: Path, base: str | None, proposed_ref: str) -> str | None:
128
+ from . import git
129
+
130
+ if base is not None:
131
+ return git.head(repository, base)
132
+ return git.default_base(repository, proposed_ref)
133
+
134
+
135
+ def _check_append_only(
136
+ repository: Path,
137
+ root: Path,
138
+ base: str,
139
+ head: str | None,
140
+ errors: list[dict[str, Any]],
141
+ ) -> None:
142
+ from . import git
143
+
144
+ pathspec = _record_prefix(repository, root)
145
+ for status, path in git.diff_status(repository, base, head, pathspec):
146
+ target = (
147
+ git.show_file(repository, head, path)
148
+ if head is not None
149
+ else (repository / path).read_bytes()
150
+ if (repository / path).exists()
151
+ else None
152
+ )
153
+ if status == "A" and target is not None and target.endswith(b"\n"):
154
+ continue
155
+ if status == "M":
156
+ original = git.show_file(repository, base, path)
157
+ if (
158
+ original is not None
159
+ and target is not None
160
+ and target.startswith(original)
161
+ and target.endswith(b"\n")
162
+ ):
163
+ continue
164
+ _fail(
165
+ errors,
166
+ "CUFF_LEDGER_REWRITE",
167
+ "Ledger changes must only append complete lines",
168
+ path=path,
169
+ status=status,
170
+ )
171
+
172
+
173
+ def _evidence_fresh(
174
+ root: Path,
175
+ evidence: dict[str, Any],
176
+ head: str | None,
177
+ git_context: tuple[Path, str],
178
+ ) -> bool:
179
+ from . import git
180
+
181
+ repository, proposed = git_context
182
+ try:
183
+ evidence_commit = git.head(repository, evidence["provenance"]["commit"])
184
+ if not git.is_ancestor(evidence_commit, proposed, repository):
185
+ return False
186
+ changed = git.changed_files(repository, evidence_commit, proposed if head is not None else None)
187
+ return not any(not _is_record_path(repository, root, path) for path in changed)
188
+ except CuffError:
189
+ return False
190
+
191
+
192
+ def _record_prefix(repository: Path, root: Path) -> str:
193
+ try:
194
+ return (root / RECORDS_DIR).resolve().relative_to(repository.resolve()).as_posix()
195
+ except ValueError as exc:
196
+ raise CuffError("CUFF_NOT_A_REPOSITORY", "Cuff workspace is outside the selected repository") from exc
197
+
198
+
199
+ def _is_record_path(repository: Path, root: Path, path: str) -> bool:
200
+ prefix = _record_prefix(repository, root)
201
+ normalized = path.replace("\\", "/")
202
+ return normalized == prefix or normalized.startswith(prefix + "/")
203
+
204
+
205
+ def _fail(errors: list[dict[str, Any]], code: str, message: str, **context: Any) -> None:
206
+ errors.append({"code": code, "message": message, **context})
cuff/git.py ADDED
@@ -0,0 +1,113 @@
1
+ """The few Git operations required for provenance and freshness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .errors import CuffError
9
+
10
+
11
+ def _run(args: list[str], *, root: Path | str | None = None, check: bool = True) -> str:
12
+ try:
13
+ process = subprocess.run(
14
+ ["git", *args],
15
+ cwd=root,
16
+ text=True,
17
+ stdout=subprocess.PIPE,
18
+ stderr=subprocess.PIPE,
19
+ timeout=15,
20
+ check=False,
21
+ )
22
+ except (OSError, subprocess.TimeoutExpired) as exc:
23
+ raise CuffError("CUFF_GIT_FAILED", f"Git command could not complete: {exc}") from exc
24
+ if check and process.returncode != 0:
25
+ raise CuffError(
26
+ "CUFF_GIT_FAILED",
27
+ process.stderr.strip() or "Git command failed",
28
+ {"command": ["git", *args]},
29
+ )
30
+ return process.stdout
31
+
32
+
33
+ def repo_root(cwd: Path | str | None = None) -> Path:
34
+ value = _run(["rev-parse", "--show-toplevel"], root=cwd, check=False).strip()
35
+ if not value:
36
+ raise CuffError("CUFF_NOT_A_REPOSITORY", "Cuff must run inside a Git repository")
37
+ return Path(value).resolve()
38
+
39
+
40
+ def head(root: Path, ref: str = "HEAD") -> str:
41
+ value = _run(["rev-parse", "--verify", f"{ref}^{{commit}}"], root=root, check=False).strip()
42
+ if not value:
43
+ raise CuffError("CUFF_GIT_REF_INVALID", f"Git ref cannot be resolved: {ref}")
44
+ return value
45
+
46
+
47
+ def is_ancestor(ancestor: str, descendant: str, root: Path) -> bool:
48
+ try:
49
+ process = subprocess.run(
50
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
51
+ cwd=root,
52
+ stdout=subprocess.DEVNULL,
53
+ stderr=subprocess.PIPE,
54
+ timeout=15,
55
+ check=False,
56
+ )
57
+ except (OSError, subprocess.TimeoutExpired) as exc:
58
+ raise CuffError("CUFF_GIT_FAILED", f"Git ancestry check could not complete: {exc}") from exc
59
+ if process.returncode in (0, 1):
60
+ return process.returncode == 0
61
+ raise CuffError("CUFF_GIT_FAILED", process.stderr.decode().strip() or "Git ancestry check failed")
62
+
63
+
64
+ def default_base(root: Path, proposed_head: str = "HEAD") -> str | None:
65
+ parent = _run(["rev-parse", "--verify", f"{proposed_head}^"], root=root, check=False).strip()
66
+ return parent or None
67
+
68
+
69
+ def changed_files(root: Path, start: str, end: str | None = None) -> list[str]:
70
+ args = ["-c", "core.quotePath=false", "diff", "--name-only", start]
71
+ if end is not None:
72
+ args.append(end)
73
+ return [line for line in _run(args, root=root).splitlines() if line]
74
+
75
+
76
+ def dirty_paths(root: Path) -> list[str]:
77
+ tracked = _run(
78
+ ["-c", "core.quotePath=false", "diff", "--name-only", "HEAD"],
79
+ root=root,
80
+ ).splitlines()
81
+ untracked = _run(
82
+ ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"],
83
+ root=root,
84
+ ).splitlines()
85
+ return sorted({path for path in (*tracked, *untracked) if path})
86
+
87
+
88
+ def diff_status(root: Path, base: str, head_ref: str | None, pathspec: str) -> list[tuple[str, str]]:
89
+ args = ["-c", "core.quotePath=false", "diff", "--name-status", "--no-renames", base]
90
+ if head_ref is not None:
91
+ args.append(head_ref)
92
+ args.extend(["--", pathspec])
93
+ rows: list[tuple[str, str]] = []
94
+ for line in _run(args, root=root).splitlines():
95
+ status, separator, path = line.partition("\t")
96
+ if separator:
97
+ rows.append((status, path))
98
+ return rows
99
+
100
+
101
+ def show_file(root: Path, ref: str, path: str) -> bytes | None:
102
+ try:
103
+ process = subprocess.run(
104
+ ["git", "show", f"{ref}:{path}"],
105
+ cwd=root,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.DEVNULL,
108
+ timeout=15,
109
+ check=False,
110
+ )
111
+ except (OSError, subprocess.TimeoutExpired) as exc:
112
+ raise CuffError("CUFF_GIT_FAILED", f"Historical ledger could not be read: {exc}") from exc
113
+ return process.stdout if process.returncode == 0 else None