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/subject.py ADDED
@@ -0,0 +1,180 @@
1
+ """Closed subject identities and deterministic filesystem manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .errors import CuffError
14
+
15
+
16
+ SUBJECT_FIELDS = {"kind", "ref", "digest"}
17
+ SUBJECT_KIND_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,119}$")
18
+ SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
19
+ FILESYSTEM_KINDS = {"file", "tree"}
20
+ MAX_REF_BYTES = 2048
21
+ MAX_FILES = 10_000
22
+ MAX_BYTES = 256 * 1024 * 1024
23
+ MAX_DEPTH = 64
24
+ MAX_NAME_BYTES = 255
25
+
26
+
27
+ def declared_subject(kind: str, ref: str, digest: str) -> dict[str, str]:
28
+ subject = {"kind": kind, "ref": ref, "digest": digest}
29
+ validate_subject(subject)
30
+ if kind in FILESYSTEM_KINDS:
31
+ raise CuffError(
32
+ "CUFF_SUBJECT_INVALID",
33
+ "The file and tree subject kinds are reserved for --subject-path",
34
+ )
35
+ return subject
36
+
37
+
38
+ def validate_subject(value: Any) -> dict[str, str]:
39
+ if not isinstance(value, dict) or set(value) != SUBJECT_FIELDS:
40
+ raise CuffError("CUFF_SUBJECT_INVALID", "Subject fields are invalid")
41
+ kind = value.get("kind")
42
+ ref = value.get("ref")
43
+ digest = value.get("digest")
44
+ if not isinstance(kind, str) or SUBJECT_KIND_RE.fullmatch(kind) is None:
45
+ raise CuffError("CUFF_SUBJECT_INVALID", "Subject kind must be a bounded lowercase token")
46
+ if (
47
+ not isinstance(ref, str)
48
+ or not ref
49
+ or len(ref.encode("utf-8")) > MAX_REF_BYTES
50
+ or any(ord(character) < 32 or ord(character) == 127 for character in ref)
51
+ ):
52
+ raise CuffError("CUFF_SUBJECT_INVALID", "Subject ref must be bounded text without controls")
53
+ if not isinstance(digest, str) or SHA256_RE.fullmatch(digest) is None:
54
+ raise CuffError("CUFF_SUBJECT_INVALID", "Subject digest must be lowercase SHA-256")
55
+ return {"kind": kind, "ref": ref, "digest": digest}
56
+
57
+
58
+ def filesystem_subject(root: Path, path: Path) -> dict[str, str]:
59
+ workspace = root.resolve()
60
+ target, reference = _bounded_target(workspace, path)
61
+ target_stat = target.stat(follow_symlinks=False)
62
+ if stat.S_ISREG(target_stat.st_mode):
63
+ entries = [_file_entry(target, reference, target_stat)]
64
+ kind = "file"
65
+ elif stat.S_ISDIR(target_stat.st_mode):
66
+ entries = _tree_entries(workspace, target)
67
+ kind = "tree"
68
+ else:
69
+ raise CuffError(
70
+ "CUFF_SUBJECT_PATH_INVALID",
71
+ "Filesystem subjects must be regular files or directories",
72
+ {"path": str(path)},
73
+ )
74
+ total_bytes = sum(entry["bytes"] for entry in entries)
75
+ if len(entries) > MAX_FILES or total_bytes > MAX_BYTES:
76
+ raise CuffError(
77
+ "CUFF_SUBJECT_BOUNDS",
78
+ "Filesystem subject exceeds the file or byte bound",
79
+ {"files": len(entries), "bytes": total_bytes},
80
+ )
81
+ manifest = {"format": "cuff-manifest-1", "entries": entries}
82
+ encoded = json.dumps(
83
+ manifest,
84
+ sort_keys=True,
85
+ separators=(",", ":"),
86
+ ensure_ascii=False,
87
+ allow_nan=False,
88
+ ).encode()
89
+ return {"kind": kind, "ref": reference, "digest": "sha256:" + hashlib.sha256(encoded).hexdigest()}
90
+
91
+
92
+ def current_subject(root: Path, subject: dict[str, str]) -> dict[str, str]:
93
+ validated = validate_subject(subject)
94
+ if validated["kind"] not in FILESYSTEM_KINDS:
95
+ return validated
96
+ current = filesystem_subject(root, Path(validated["ref"]))
97
+ if current["kind"] != validated["kind"]:
98
+ raise CuffError("CUFF_SUBJECT_CHANGED", "Filesystem subject kind changed")
99
+ return current
100
+
101
+
102
+ def _bounded_target(workspace: Path, path: Path) -> tuple[Path, str]:
103
+ raw = path.expanduser()
104
+ candidate = raw if raw.is_absolute() else workspace / raw
105
+ lexical = Path(os.path.abspath(candidate))
106
+ try:
107
+ relative = lexical.relative_to(workspace)
108
+ except ValueError as exc:
109
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Subject path escapes the workspace") from exc
110
+ if relative.parts[:1] == (".cuff",):
111
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Cuff controller state cannot be a subject")
112
+ current = workspace
113
+ for part in relative.parts:
114
+ if len(os.fsencode(part)) > MAX_NAME_BYTES:
115
+ raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject name exceeds the bound")
116
+ current /= part
117
+ if current.is_symlink():
118
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects must not contain symlinks")
119
+ if not lexical.exists():
120
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subject does not exist")
121
+ resolved = lexical.resolve()
122
+ try:
123
+ resolved.relative_to(workspace)
124
+ except ValueError as exc:
125
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Subject path escapes the workspace") from exc
126
+ reference = relative.as_posix() if relative.parts else "."
127
+ return resolved, reference
128
+
129
+
130
+ def _tree_entries(workspace: Path, target: Path) -> list[dict[str, Any]]:
131
+ entries: list[dict[str, Any]] = []
132
+ total_bytes = 0
133
+ for directory, names, filenames in os.walk(target, topdown=True, followlinks=False):
134
+ directory_path = Path(directory)
135
+ relative_directory = directory_path.relative_to(target)
136
+ if len(relative_directory.parts) > MAX_DEPTH:
137
+ raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject exceeds the depth bound")
138
+ for name in [*names, *filenames]:
139
+ if len(os.fsencode(name)) > MAX_NAME_BYTES:
140
+ raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject name exceeds the bound")
141
+
142
+ kept_names: list[str] = []
143
+ for name in names:
144
+ candidate = directory_path / name
145
+ workspace_relative = candidate.relative_to(workspace)
146
+ if workspace_relative.parts[:1] == (".cuff",):
147
+ continue
148
+ directory_mode = candidate.stat(follow_symlinks=False).st_mode
149
+ if stat.S_ISLNK(directory_mode):
150
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects must not contain symlinks")
151
+ if not stat.S_ISDIR(directory_mode):
152
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects contain a special entry")
153
+ kept_names.append(name)
154
+ names[:] = sorted(kept_names, key=os.fsencode)
155
+
156
+ for name in sorted(filenames, key=os.fsencode):
157
+ candidate = directory_path / name
158
+ metadata = candidate.stat(follow_symlinks=False)
159
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
160
+ raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects contain a special entry")
161
+ relative = candidate.relative_to(workspace).as_posix()
162
+ entries.append(_file_entry(candidate, relative, metadata))
163
+ total_bytes += metadata.st_size
164
+ if len(entries) > MAX_FILES or total_bytes > MAX_BYTES:
165
+ raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject exceeds the file or byte bound")
166
+ entries.sort(key=lambda entry: os.fsencode(entry["path"]))
167
+ return entries
168
+
169
+
170
+ def _file_entry(path: Path, reference: str, metadata: os.stat_result) -> dict[str, Any]:
171
+ digest = hashlib.sha256()
172
+ with path.open("rb") as handle:
173
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
174
+ digest.update(chunk)
175
+ return {
176
+ "path": reference,
177
+ "mode": stat.S_IMODE(metadata.st_mode),
178
+ "bytes": metadata.st_size,
179
+ "digest": "sha256:" + digest.hexdigest(),
180
+ }
cuff/workspace.py ADDED
@@ -0,0 +1,204 @@
1
+ """Safe selection and initialization of one Git-root Cuff workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import git
12
+ from .errors import CuffError
13
+ from .ledger import RECORDS_DIR, init as init_records
14
+
15
+
16
+ MAX_PARENT_WALK = 64
17
+ PROJECT_PATH = Path(".cuff/project.json")
18
+ PROJECT_MARKER = {"schema": 1}
19
+ PROJECT_MARKER_BYTES = b'{"schema":1}\n'
20
+
21
+
22
+ def initialize_workspace(
23
+ explicit: Path | None = None,
24
+ *,
25
+ cwd: Path | None = None,
26
+ ) -> dict[str, Any]:
27
+ """Initialize only the caller-selected directory or current directory."""
28
+ start = (cwd or Path.cwd()).resolve()
29
+ root = _existing_directory(explicit or start, start)
30
+ _require_git_root(root)
31
+ state = root / ".cuff"
32
+ marker = root / PROJECT_PATH
33
+ _require_safe_state_paths(state, marker)
34
+ _require_safe_records_path(root / RECORDS_DIR)
35
+
36
+ existed = marker.exists()
37
+ if existed:
38
+ _read_marker(marker)
39
+ else:
40
+ state.mkdir(mode=0o755, exist_ok=True)
41
+ init_records(root)
42
+ if not existed:
43
+ _create_marker(marker)
44
+ return {
45
+ "ok": True,
46
+ "status": "already_initialized" if existed else "initialized",
47
+ "workspace": str(root),
48
+ "marker": dict(PROJECT_MARKER),
49
+ }
50
+
51
+
52
+ def find_workspace(
53
+ explicit: Path | None = None,
54
+ *,
55
+ cwd: Path | None = None,
56
+ ) -> Path:
57
+ """Find and validate the explicit or nearest parent Cuff workspace."""
58
+ start = (cwd or Path.cwd()).resolve()
59
+ if explicit is not None:
60
+ root = _existing_directory(explicit, start)
61
+ _validate_project(root)
62
+ return root
63
+
64
+ current = start
65
+ for _ in range(MAX_PARENT_WALK):
66
+ state = current / ".cuff"
67
+ marker = current / PROJECT_PATH
68
+ if state.is_symlink() or (state.exists() and not state.is_dir()):
69
+ raise CuffError(
70
+ "CUFF_PATH_INVALID",
71
+ "Cuff state path in workspace discovery is unsafe",
72
+ {"path": str(state)},
73
+ )
74
+ if marker.exists() or marker.is_symlink():
75
+ _validate_project(current)
76
+ return current
77
+ parent = current.parent
78
+ if parent == current:
79
+ break
80
+ current = parent
81
+ raise _not_initialized(start)
82
+
83
+
84
+ def _validate_project(root: Path) -> None:
85
+ state = root / ".cuff"
86
+ marker = root / PROJECT_PATH
87
+ if not marker.exists() and not marker.is_symlink():
88
+ raise _not_initialized(root)
89
+ _require_safe_state_paths(state, marker)
90
+ _read_marker(marker)
91
+ _require_git_root(root)
92
+ records = root / RECORDS_DIR
93
+ _require_safe_records_path(records)
94
+ if not records.is_dir():
95
+ raise CuffError(
96
+ "CUFF_PROJECT_INVALID",
97
+ "Cuff records directory is missing or unsafe; rerun cuff init",
98
+ {"path": str(records)},
99
+ )
100
+
101
+
102
+ def _require_git_root(root: Path) -> None:
103
+ repository = git.repo_root(root)
104
+ if repository != root.resolve():
105
+ raise CuffError(
106
+ "CUFF_WORKSPACE_NOT_ROOT",
107
+ "Cuff workspace must be the Git worktree root",
108
+ {"workspace": str(root.resolve()), "repository": str(repository)},
109
+ )
110
+
111
+
112
+ def _existing_directory(path: Path, start: Path) -> Path:
113
+ candidate = path.expanduser()
114
+ if not candidate.is_absolute():
115
+ candidate = start / candidate
116
+ if candidate.is_symlink() or not candidate.is_dir():
117
+ raise CuffError(
118
+ "CUFF_WORKSPACE_INVALID",
119
+ "Workspace must be one existing non-symlink directory",
120
+ {"path": str(candidate)},
121
+ )
122
+ return candidate.resolve()
123
+
124
+
125
+ def _require_safe_state_paths(state: Path, marker: Path) -> None:
126
+ if state.is_symlink() or (state.exists() and not state.is_dir()):
127
+ raise CuffError(
128
+ "CUFF_PATH_INVALID",
129
+ "Cuff state directory must be a non-symlink directory",
130
+ {"path": str(state)},
131
+ )
132
+ if marker.is_symlink() or (marker.exists() and not marker.is_file()):
133
+ raise CuffError(
134
+ "CUFF_PATH_INVALID",
135
+ "Cuff project marker must be a regular non-symlink file",
136
+ {"path": str(marker)},
137
+ )
138
+
139
+
140
+ def _require_safe_records_path(records: Path) -> None:
141
+ if records.is_symlink() or (records.exists() and not records.is_dir()):
142
+ raise CuffError(
143
+ "CUFF_PATH_INVALID",
144
+ "Cuff records path must be a non-symlink directory",
145
+ {"path": str(records)},
146
+ )
147
+
148
+
149
+ def _read_marker(path: Path) -> dict[str, int]:
150
+ try:
151
+ content = path.read_bytes()
152
+ data = json.loads(content, object_pairs_hook=_no_duplicate_keys)
153
+ except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
154
+ raise CuffError(
155
+ "CUFF_PROJECT_INCOMPATIBLE",
156
+ "Cuff project marker is incompatible; archive or remove only the marker and rerun cuff init",
157
+ {"path": str(path)},
158
+ ) from exc
159
+ if data != PROJECT_MARKER or content != PROJECT_MARKER_BYTES:
160
+ raise CuffError(
161
+ "CUFF_PROJECT_INCOMPATIBLE",
162
+ "Cuff project marker is incompatible; archive or remove only the marker and rerun cuff init",
163
+ {"path": str(path)},
164
+ )
165
+ return data
166
+
167
+
168
+ def _create_marker(path: Path) -> None:
169
+ temporary: Path | None = None
170
+ try:
171
+ with tempfile.NamedTemporaryFile("wb", dir=path.parent, prefix=".project.", delete=False) as handle:
172
+ temporary = Path(handle.name)
173
+ handle.write(PROJECT_MARKER_BYTES)
174
+ handle.flush()
175
+ os.fsync(handle.fileno())
176
+ try:
177
+ os.link(temporary, path)
178
+ except FileExistsError:
179
+ _read_marker(path)
180
+ descriptor = os.open(path.parent, os.O_RDONLY)
181
+ try:
182
+ os.fsync(descriptor)
183
+ finally:
184
+ os.close(descriptor)
185
+ finally:
186
+ if temporary is not None and temporary.exists():
187
+ temporary.unlink()
188
+
189
+
190
+ def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
191
+ data: dict[str, Any] = {}
192
+ for key, value in pairs:
193
+ if key in data:
194
+ raise ValueError(f"duplicate JSON key: {key}")
195
+ data[key] = value
196
+ return data
197
+
198
+
199
+ def _not_initialized(path: Path) -> CuffError:
200
+ return CuffError(
201
+ "CUFF_PROJECT_NOT_INITIALIZED",
202
+ "No Cuff project was found; run cuff init or pass --workspace",
203
+ {"path": str(path)},
204
+ )
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuff-cli
3
+ Version: 0.1.0
4
+ Summary: Cuff subject-bound claim, evidence, and verification gate.
5
+ Author: Fab7
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ <p align="center">
13
+ <img src="docs/assets/banner.svg" alt="Cuff check flow: bind the subject, observe the verifier, require fresh evidence" width="100%" />
14
+ </p>
15
+
16
+ # Cuff
17
+
18
+ **Make claims checkable. Reject evidence when stale.**
19
+
20
+ Cuff ties one completion claim to one exact subject, runs the verifier you
21
+ choose, and checks whether the latest passing evidence still matches the
22
+ current Git state.
23
+
24
+ It turns a completion statement into a durable, checkable record without
25
+ deciding what should prove the work or what action should follow.
26
+
27
+ ## Requirements
28
+
29
+ - Python 3.11 or newer;
30
+ - `uv` on `PATH` (`0.11.32` is the tested recommendation); and
31
+ - an existing Git worktree. Its root is the only valid Cuff workspace.
32
+
33
+ Git is mandatory. Cuff never initializes a repository, selects another
34
+ worktree, or stages, commits, fetches, pushes, releases, or deploys anything.
35
+
36
+ ## Install
37
+
38
+ Install a released version as a standard uv-managed tool:
39
+
40
+ ```bash
41
+ uv tool install cuff-cli==0.1.0
42
+ cuff --version
43
+ ```
44
+
45
+ For local development, install the checkout explicitly:
46
+
47
+ ```bash
48
+ uv tool install --editable .
49
+ ```
50
+
51
+ Cuff has no runtime dependencies. It is distributed as a standard wheel and
52
+ source distribution; it contains no bundled Python or native executable.
53
+
54
+ ## Five-command quickstart
55
+
56
+ Run initialization at the exact Git worktree root:
57
+
58
+ ```bash
59
+ cuff init --json
60
+ git add .cuff/project.json
61
+ git commit -m "Initialize Cuff"
62
+ ```
63
+
64
+ The marker is exactly `{"schema":1}` and records live under
65
+ `.cuff/records/`. An incompatible marker is never rewritten or migrated.
66
+
67
+ The preferred path atomically appends a claim and its observed evidence:
68
+
69
+ ```bash
70
+ cuff seal \
71
+ --work-item task-1 \
72
+ --summary "Implementation complete" \
73
+ --subject-path src \
74
+ --json \
75
+ -- python -m pytest
76
+
77
+ cuff check --work-item task-1 --json
78
+ ```
79
+
80
+ The split path is available when the claim must exist before verification:
81
+
82
+ ```bash
83
+ cuff claim \
84
+ --work-item task-1 \
85
+ --summary "Implementation complete" \
86
+ --subject-path src \
87
+ --json
88
+
89
+ cuff verify \
90
+ --work-item task-1 \
91
+ --claim rec_REPLACE_ME \
92
+ --json \
93
+ -- python -m pytest
94
+ ```
95
+
96
+ The public surface is exactly:
97
+
98
+ ```text
99
+ cuff init
100
+ cuff claim
101
+ cuff verify
102
+ cuff seal
103
+ cuff check
104
+ ```
105
+
106
+ Every claim, verification, seal, and check names its work item explicitly.
107
+ Declared subjects use the complete `{kind, ref, digest}` identity; file and
108
+ tree subjects use `--subject-path` and a Cuff-computed manifest digest.
109
+
110
+ ## Proof boundary
111
+
112
+ - Claims and evidence are closed generation-1 JSONL records.
113
+ - Every evidence record contains the `HEAD` commit observed before execution.
114
+ - Verifier argv is executed literally without a shell.
115
+ - Non-ledger dirtiness before or after verification records no evidence.
116
+ - `seal` appends its linked pair in one locked atomic replacement.
117
+ - `check` enforces subject freshness, commit ancestry, changed paths,
118
+ non-ledger cleanliness, and append-only ledger changes.
119
+
120
+ Cuff treats verifier argv as opaque. It does not select the command, import an
121
+ extension, interpret domain output, or grant merge, release, deployment,
122
+ spend, or residual-risk authority.
123
+
124
+ ## Static host integrations
125
+
126
+ Small native assets live in [`plugins/claude/cuff`](plugins/claude/cuff) and
127
+ [`plugins/codex/cuff`](plugins/codex/cuff). The corresponding host plugin
128
+ manager owns their installation and removal. These assets require only the
129
+ uv-managed `cuff` executable on `PATH`; Cuff itself does not install plugins.
130
+
131
+ ```bash
132
+ # Codex
133
+ codex plugin marketplace add fab7hq/cuff --ref v0.1.0
134
+ codex plugin add cuff@fab7hq
135
+
136
+ # Claude Code
137
+ claude plugin marketplace add fab7hq/cuff@v0.1.0
138
+ claude plugin install cuff@fab7hq --scope user
139
+ ```
140
+
141
+ See [RUNBOOK.md](RUNBOOK.md) for operations, [the architecture overview](docs/architecture/overview.md)
142
+ for ownership, and [the ledger contract](docs/architecture/ledger.md) for the
143
+ record and gate invariants.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ uv sync --locked
149
+ uv run --locked python -m pytest
150
+ uv run --locked python -m compileall -q core/cuff
151
+ uv build
152
+ git diff --check
153
+ ```
154
+
155
+ ## Community and support
156
+
157
+ - Use [Cuff Discussions](https://github.com/fab7hq/cuff/discussions) for usage questions and design proposals.
158
+ - Report reproducible defects through [GitHub Issues](https://github.com/fab7hq/cuff/issues/new/choose).
159
+ - Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change.
160
+ - Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
161
+
162
+ Cuff is licensed under the [Apache License 2.0](LICENSE).
@@ -0,0 +1,15 @@
1
+ cuff/__init__.py,sha256=j7VeQDzYOCfi6j72wCBo99g9xUmJxyVZRsyBqFvd-iM,100
2
+ cuff/__main__.py,sha256=8F2QDwc1Aevh_MqyENACDK1freyN9sNexmi7IKlbfGU,53
3
+ cuff/cli.py,sha256=wM3jJQi5c_ozBrXDGggjjohNnSkCwES9W3J5d7jesAg,8514
4
+ cuff/errors.py,sha256=Hl_g101k6efJNKIwVnAbG0_7kstCT3ZU_ix1CtEp85E,489
5
+ cuff/gate.py,sha256=t4LUXx1ON4hf6u1eftstyJXRtqNXhtA0MsOaootSUp0,6493
6
+ cuff/git.py,sha256=bM-x67Q_vD29dENvm8bgl5dFWDFtUig1RRYN8fl8B68,4029
7
+ cuff/ledger.py,sha256=Us73NcbxDSDFOXmLRHBDamt9x7OE1PoUL_FaujgOn8I,22763
8
+ cuff/subject.py,sha256=0S2e0dz1UOM8WQoBO21WjAW3XNJS8zyRUn8t-UEdqNY,7576
9
+ cuff/workspace.py,sha256=9ANo_L01GFzD3GURlWDDXS_q5iST2dFfzIWAEazT6NI,6465
10
+ cuff_cli-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
11
+ cuff_cli-0.1.0.dist-info/METADATA,sha256=OXbJ992OkxEGR0nw2kC_2I9734jGTRpN6ElIS1GY3Do,4725
12
+ cuff_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ cuff_cli-0.1.0.dist-info/entry_points.txt,sha256=xDe9VI-R88K9P5llkgU6MOH44Ui0_7SeYr7mLQkS1_U,39
14
+ cuff_cli-0.1.0.dist-info/top_level.txt,sha256=zOwA6w7ZmmamL--uv26Xgrfp9rFSbBIt-69kDEPjK-k,5
15
+ cuff_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cuff = cuff.cli:main