assertion-cli 0.1.0__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.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: assertion-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Assertion API
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: python-dotenv>=1.0
10
+ Requires-Dist: typer>=0.24.1
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8.0; extra == "test"
13
+ Requires-Dist: respx>=0.22; extra == "test"
14
+
15
+ # Assertion CLI
16
+
17
+ CLI for the Assertion API.
18
+
19
+ ## Usage
20
+
21
+ The CLI currently targets a local backend at `http://localhost:8000`.
22
+
23
+ Run locally from the workspace:
24
+
25
+ ```bash
26
+ uv run --package assertion-cli asrt --help
27
+ ```
28
+
29
+ Install from Git as a `uv` tool:
30
+
31
+ ```bash
32
+ uv tool install git+ssh://git@github.com/prooflayer-ai/backend.git#subdirectory=cli
33
+ ```
34
+
35
+ The CLI package declares all of its direct runtime dependencies. At the moment
36
+ that set is `httpx`, `pydantic`, and `typer`.
37
+
38
+ After installation:
39
+
40
+ ```bash
41
+ asrt stacks
42
+ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
43
+ asrt checkpoint --continue "Implemented Y"
44
+ asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
45
+ asrt verify
46
+ ```
47
+
48
+ This expects the installer to already have GitHub SSH access to `prooflayer-ai/backend`.
@@ -0,0 +1,34 @@
1
+ # Assertion CLI
2
+
3
+ CLI for the Assertion API.
4
+
5
+ ## Usage
6
+
7
+ The CLI currently targets a local backend at `http://localhost:8000`.
8
+
9
+ Run locally from the workspace:
10
+
11
+ ```bash
12
+ uv run --package assertion-cli asrt --help
13
+ ```
14
+
15
+ Install from Git as a `uv` tool:
16
+
17
+ ```bash
18
+ uv tool install git+ssh://git@github.com/prooflayer-ai/backend.git#subdirectory=cli
19
+ ```
20
+
21
+ The CLI package declares all of its direct runtime dependencies. At the moment
22
+ that set is `httpx`, `pydantic`, and `typer`.
23
+
24
+ After installation:
25
+
26
+ ```bash
27
+ asrt stacks
28
+ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
29
+ asrt checkpoint --continue "Implemented Y"
30
+ asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
31
+ asrt verify
32
+ ```
33
+
34
+ This expects the installer to already have GitHub SSH access to `prooflayer-ai/backend`.
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, TypeVar
5
+
6
+ import httpx
7
+ import typer
8
+ from dotenv import find_dotenv, load_dotenv
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from models import (
12
+ CheckpointResponse,
13
+ DecisionResponse,
14
+ ErrorResponse,
15
+ InitResponse,
16
+ StatusResponse,
17
+ VerifyResponse,
18
+ )
19
+
20
+ T = TypeVar("T", bound=BaseModel)
21
+
22
+ DEFAULT_BASE_URL = "http://localhost:8000"
23
+ DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 300.0
24
+ API_TOKEN_ENV = "ASSERTION_API_TOKEN"
25
+
26
+ # Walk up from cwd looking for a .env so the user can drop their token in the
27
+ # repo root once and forget it. override=False means a real shell `export`
28
+ # still wins over the file — useful for CI.
29
+ _dotenv_path = find_dotenv(usecwd=True)
30
+ if _dotenv_path:
31
+ load_dotenv(_dotenv_path, override=False)
32
+
33
+
34
+ def _base_url() -> str:
35
+ return os.environ.get("ASSERTION_BASE_URL", DEFAULT_BASE_URL)
36
+
37
+
38
+ def _auth_headers() -> dict[str, str]:
39
+ token = (os.environ.get(API_TOKEN_ENV) or "").strip()
40
+ if not token:
41
+ typer.echo(
42
+ f"Missing {API_TOKEN_ENV}. Generate a token at Settings → Authentication "
43
+ "in the dashboard, then save it in a .env at your repo root:\n\n"
44
+ f" echo '{API_TOKEN_ENV}=alk_...' >> .env\n\n"
45
+ "(Or export it in your shell.)",
46
+ err=True,
47
+ )
48
+ raise typer.Exit(code=1)
49
+ return {"Authorization": f"Bearer {token}"}
50
+
51
+
52
+ def _parse_response(response: httpx.Response, model: type[T]) -> T:
53
+ try:
54
+ return model.model_validate_json(response.text)
55
+ except ValidationError as exc:
56
+ raise ValueError(f"Invalid API response: {exc}") from exc
57
+
58
+
59
+ def _summarize_error(response: httpx.Response) -> str:
60
+ try:
61
+ return ErrorResponse.model_validate_json(response.text).error
62
+ except ValidationError:
63
+ body = response.text.strip()
64
+ return body[:200] if body else "<empty response body>"
65
+
66
+
67
+ class AssertionClient:
68
+ def __init__(self) -> None:
69
+ self.base_url = _base_url()
70
+
71
+ def _request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ model: type[T],
76
+ *,
77
+ timeout: float = 30.0,
78
+ **kwargs: Any,
79
+ ) -> T:
80
+ url = f"{self.base_url}{path}"
81
+ headers = {**_auth_headers(), **kwargs.pop("headers", {})}
82
+ try:
83
+ response = httpx.request(
84
+ method, url, timeout=timeout, headers=headers, **kwargs
85
+ )
86
+ response.raise_for_status()
87
+ except httpx.HTTPStatusError as exc:
88
+ summary = _summarize_error(exc.response)
89
+ typer.echo(
90
+ f"Request failed with status {exc.response.status_code}: {summary}",
91
+ err=True,
92
+ )
93
+ raise typer.Exit(code=1) from exc
94
+ except httpx.ReadTimeout as exc:
95
+ typer.echo(f"Request to {url} timed out after {timeout:.0f}s.", err=True)
96
+ raise typer.Exit(code=1) from exc
97
+ except httpx.HTTPError as exc:
98
+ typer.echo(f"Failed to connect to Assertion API at {url}: {exc}", err=True)
99
+ raise typer.Exit(code=1) from exc
100
+
101
+ try:
102
+ return _parse_response(response, model)
103
+ except ValueError as exc:
104
+ typer.echo(str(exc), err=True)
105
+ raise typer.Exit(code=1) from exc
106
+
107
+ def init(self) -> InitResponse:
108
+ return self._request("POST", "/api/v0/agent/init", InitResponse, timeout=10.0)
109
+
110
+ def _request_no_content(
111
+ self,
112
+ method: str,
113
+ path: str,
114
+ *,
115
+ timeout: float = 30.0,
116
+ **kwargs: Any,
117
+ ) -> DecisionResponse:
118
+ url = f"{self.base_url}{path}"
119
+ headers = {**_auth_headers(), **kwargs.pop("headers", {})}
120
+ try:
121
+ response = httpx.request(
122
+ method, url, timeout=timeout, headers=headers, **kwargs
123
+ )
124
+ response.raise_for_status()
125
+ except httpx.HTTPStatusError as exc:
126
+ summary = _summarize_error(exc.response)
127
+ typer.echo(
128
+ f"Request failed with status {exc.response.status_code}: {summary}",
129
+ err=True,
130
+ )
131
+ raise typer.Exit(code=1) from exc
132
+ except httpx.HTTPError as exc:
133
+ typer.echo(f"Failed to connect to Assertion API at {url}: {exc}", err=True)
134
+ raise typer.Exit(code=1) from exc
135
+
136
+ return DecisionResponse()
137
+
138
+ def stacks(self) -> list:
139
+ """Fetch available stacks (read-only, no session created)."""
140
+ from models import VerificationStack
141
+ from pydantic import TypeAdapter
142
+
143
+ url = f"{self.base_url}/api/v0/agent/stacks"
144
+ try:
145
+ response = httpx.get(url, timeout=10.0, headers=_auth_headers())
146
+ response.raise_for_status()
147
+ except httpx.HTTPStatusError as exc:
148
+ summary = _summarize_error(exc.response)
149
+ typer.echo(
150
+ f"Request failed with status {exc.response.status_code}: {summary}",
151
+ err=True,
152
+ )
153
+ raise typer.Exit(code=1) from exc
154
+ except httpx.HTTPError as exc:
155
+ typer.echo(f"Failed to connect to Assertion API at {url}: {exc}", err=True)
156
+ raise typer.Exit(code=1) from exc
157
+
158
+ try:
159
+ adapter = TypeAdapter(list[VerificationStack])
160
+ return adapter.validate_json(response.text)
161
+ except Exception as exc:
162
+ typer.echo(f"Invalid API response: {exc}", err=True)
163
+ raise typer.Exit(code=1) from exc
164
+
165
+ def verify(
166
+ self,
167
+ *,
168
+ stack_id: str,
169
+ session_id: str,
170
+ bundle_bytes: bytes,
171
+ ) -> VerifyResponse:
172
+ path = f"/api/v0/agent/verify/{stack_id}/{session_id}"
173
+ return self._request(
174
+ "POST",
175
+ path,
176
+ VerifyResponse,
177
+ files={"bundle": ("assertion_bundle.zip", bundle_bytes, "application/zip")},
178
+ )
179
+
180
+ def checkpoint(
181
+ self,
182
+ *,
183
+ stack_id: str,
184
+ session_id: str,
185
+ bundle_bytes: bytes,
186
+ ) -> CheckpointResponse:
187
+ path = f"/api/v0/agent/checkpoint/{stack_id}/{session_id}"
188
+ return self._request(
189
+ "POST",
190
+ path,
191
+ CheckpointResponse,
192
+ timeout=DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
193
+ files={"bundle": ("assertion_bundle.zip", bundle_bytes, "application/zip")},
194
+ )
195
+
196
+ def decision(self, *, checkpoint_id: str, decision: str) -> DecisionResponse:
197
+ return self._request_no_content(
198
+ "POST",
199
+ f"/api/v0/agent/decision/{checkpoint_id}",
200
+ json={"decision": decision},
201
+ timeout=10.0,
202
+ )
203
+
204
+ def status(self, *, session_id: str) -> StatusResponse:
205
+ return self._request(
206
+ "POST",
207
+ "/api/v0/agent/status",
208
+ StatusResponse,
209
+ json={"session_id": session_id},
210
+ timeout=10.0,
211
+ )
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: assertion-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Assertion API
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: python-dotenv>=1.0
10
+ Requires-Dist: typer>=0.24.1
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8.0; extra == "test"
13
+ Requires-Dist: respx>=0.22; extra == "test"
14
+
15
+ # Assertion CLI
16
+
17
+ CLI for the Assertion API.
18
+
19
+ ## Usage
20
+
21
+ The CLI currently targets a local backend at `http://localhost:8000`.
22
+
23
+ Run locally from the workspace:
24
+
25
+ ```bash
26
+ uv run --package assertion-cli asrt --help
27
+ ```
28
+
29
+ Install from Git as a `uv` tool:
30
+
31
+ ```bash
32
+ uv tool install git+ssh://git@github.com/prooflayer-ai/backend.git#subdirectory=cli
33
+ ```
34
+
35
+ The CLI package declares all of its direct runtime dependencies. At the moment
36
+ that set is `httpx`, `pydantic`, and `typer`.
37
+
38
+ After installation:
39
+
40
+ ```bash
41
+ asrt stacks
42
+ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
43
+ asrt checkpoint --continue "Implemented Y"
44
+ asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
45
+ asrt verify
46
+ ```
47
+
48
+ This expects the installer to already have GitHub SSH access to `prooflayer-ai/backend`.
@@ -0,0 +1,28 @@
1
+ README.md
2
+ api.py
3
+ bundle.py
4
+ git.py
5
+ link.py
6
+ main.py
7
+ models.py
8
+ pyproject.toml
9
+ session.py
10
+ assertion_cli.egg-info/PKG-INFO
11
+ assertion_cli.egg-info/SOURCES.txt
12
+ assertion_cli.egg-info/dependency_links.txt
13
+ assertion_cli.egg-info/entry_points.txt
14
+ assertion_cli.egg-info/requires.txt
15
+ assertion_cli.egg-info/top_level.txt
16
+ templates/ACTIVATION.md
17
+ templates/SKILL.md
18
+ templates/__init__.py
19
+ tests/test_api.py
20
+ tests/test_bundle.py
21
+ tests/test_decision.py
22
+ tests/test_git.py
23
+ tests/test_init.py
24
+ tests/test_link.py
25
+ tests/test_main.py
26
+ tests/test_prompt.py
27
+ tests/test_session.py
28
+ tests/test_stack_resolve.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ asrt = main:app
@@ -0,0 +1,8 @@
1
+ httpx>=0.28.1
2
+ pydantic>=2.12.5
3
+ python-dotenv>=1.0
4
+ typer>=0.24.1
5
+
6
+ [test]
7
+ pytest>=8.0
8
+ respx>=0.22
@@ -0,0 +1,8 @@
1
+ api
2
+ assertion_cli_templates
3
+ bundle
4
+ git
5
+ link
6
+ main
7
+ models
8
+ session
@@ -0,0 +1,26 @@
1
+ import io
2
+ import zipfile
3
+
4
+ from models import MetadataPayload
5
+
6
+ ASSERTION_DIR_NAME = ".assertion"
7
+ DIFF_ARCHIVE_PATH = "git.diff"
8
+ METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
9
+ PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
10
+ CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
11
+
12
+
13
+ def build_bundle(
14
+ *,
15
+ metadata: MetadataPayload,
16
+ diff_text: str,
17
+ prompts_text: str,
18
+ checkpoint_text: str,
19
+ ) -> bytes:
20
+ buf = io.BytesIO()
21
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
22
+ zf.writestr(METADATA_ARCHIVE_PATH, metadata.model_dump_json(indent=2) + "\n")
23
+ zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
24
+ zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
25
+ zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
26
+ return buf.getvalue()
@@ -0,0 +1,189 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import NoReturn, Sequence
4
+
5
+ import typer
6
+
7
+
8
+ def exit_with_error(message: str) -> NoReturn:
9
+ typer.echo(message, err=True)
10
+ raise typer.Exit(code=1)
11
+
12
+
13
+ def run_git_command(repo_root: Path, args: Sequence[str]) -> str:
14
+ completed = subprocess.run(
15
+ ["git", *args],
16
+ cwd=repo_root,
17
+ capture_output=True,
18
+ text=True,
19
+ check=False,
20
+ )
21
+ if completed.returncode != 0:
22
+ message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
23
+ raise RuntimeError(message)
24
+ return completed.stdout.strip()
25
+
26
+
27
+ def find_git_root(start_path: Path) -> Path:
28
+ completed = subprocess.run(
29
+ ["git", "rev-parse", "--show-toplevel"],
30
+ cwd=start_path,
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+ if completed.returncode != 0:
36
+ exit_with_error("Current directory is not inside a git repository.")
37
+ return Path(completed.stdout.strip())
38
+
39
+
40
+ def require_head_pushed(repo_root: Path) -> None:
41
+ try:
42
+ remote_refs = run_git_command(
43
+ repo_root, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]
44
+ )
45
+ except RuntimeError as exc:
46
+ exit_with_error(f"Failed to inspect remote refs: {exc}")
47
+
48
+ refs = [
49
+ ref for ref in remote_refs.splitlines() if ref and not ref.endswith("/HEAD")
50
+ ]
51
+ if not refs:
52
+ exit_with_error(
53
+ "Current HEAD commit is not present on any remote-tracking branch."
54
+ )
55
+
56
+ for ref in refs:
57
+ completed = subprocess.run(
58
+ ["git", "merge-base", "--is-ancestor", "HEAD", ref],
59
+ cwd=repo_root,
60
+ capture_output=True,
61
+ text=True,
62
+ check=False,
63
+ )
64
+ if completed.returncode == 0:
65
+ return
66
+ if completed.returncode != 1:
67
+ message = (
68
+ completed.stderr.strip() or completed.stdout.strip() or "git failed"
69
+ )
70
+ exit_with_error(f"Failed to verify remote commit state: {message}")
71
+
72
+ exit_with_error("Current HEAD commit is not present on any remote-tracking branch.")
73
+
74
+
75
+ def get_head_sha(repo_root: Path) -> str:
76
+ try:
77
+ return run_git_command(repo_root, ["rev-parse", "HEAD"])
78
+ except RuntimeError as exc:
79
+ exit_with_error(f"Failed to get HEAD SHA: {exc}")
80
+
81
+
82
+ def get_head_branch(repo_root: Path) -> str | None:
83
+ """Return the current branch name, or None if HEAD is detached."""
84
+ try:
85
+ name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
86
+ except RuntimeError:
87
+ return None
88
+ return name if name and name != "HEAD" else None
89
+
90
+
91
+ def get_origin_github_repo(repo_root: Path) -> str | None:
92
+ """Return the current repo's GitHub `owner/name` from origin, or None if not parseable.
93
+
94
+ Accepts the common remote URL forms:
95
+ git@github.com:owner/name(.git)?
96
+ https://github.com/owner/name(.git)?
97
+ ssh://git@github.com/owner/name(.git)?
98
+ """
99
+ try:
100
+ url = run_git_command(repo_root, ["remote", "get-url", "origin"]).strip()
101
+ except RuntimeError:
102
+ return None
103
+ if not url:
104
+ return None
105
+
106
+ if url.startswith("git@github.com:"):
107
+ path = url[len("git@github.com:") :]
108
+ elif url.startswith("ssh://git@github.com/"):
109
+ path = url[len("ssh://git@github.com/") :]
110
+ elif url.startswith("https://github.com/"):
111
+ path = url[len("https://github.com/") :]
112
+ elif url.startswith("http://github.com/"):
113
+ path = url[len("http://github.com/") :]
114
+ else:
115
+ return None
116
+
117
+ path = path.rstrip("/")
118
+ if path.endswith(".git"):
119
+ path = path[: -len(".git")]
120
+ if path.count("/") != 1 or not all(path.split("/")):
121
+ return None
122
+ return path
123
+
124
+
125
+ def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
126
+ completed = subprocess.run(
127
+ [
128
+ "git",
129
+ "diff",
130
+ "--no-index",
131
+ "--src-prefix=a/",
132
+ "--dst-prefix=b/",
133
+ "--",
134
+ "/dev/null",
135
+ rel_path,
136
+ ],
137
+ cwd=repo_root,
138
+ capture_output=True,
139
+ text=True,
140
+ check=False,
141
+ )
142
+ if completed.returncode not in (0, 1):
143
+ message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
144
+ raise RuntimeError(message)
145
+ return completed.stdout.strip()
146
+
147
+
148
+ # Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
149
+ # reviewers don't flag our own bootstrap files as "unrelated changes" — they're
150
+ # generated/refreshed by `asrt init` and `asrt checkpoint` and have nothing to
151
+ # do with the customer's feature work. CLAUDE.md / AGENTS.md are intentionally
152
+ # NOT in this list: those are customer-owned files we only patch a marked
153
+ # block into, so the customer's other edits to them should still flow through.
154
+ _ASSERTION_EXCLUDED_PATHSPECS = [
155
+ ":(exclude).assertion",
156
+ ":(exclude).claude/skills/assertion-cli",
157
+ ":(exclude).agents/skills/assertion-cli",
158
+ ]
159
+
160
+
161
+ def get_uncommitted_diff(repo_root: Path) -> str:
162
+ try:
163
+ tracked = run_git_command(
164
+ repo_root, ["diff", "--", *_ASSERTION_EXCLUDED_PATHSPECS]
165
+ )
166
+ staged = run_git_command(
167
+ repo_root, ["diff", "--cached", "--", *_ASSERTION_EXCLUDED_PATHSPECS]
168
+ )
169
+
170
+ untracked_output = run_git_command(
171
+ repo_root,
172
+ [
173
+ "ls-files",
174
+ "--others",
175
+ "--exclude-standard",
176
+ "--",
177
+ *_ASSERTION_EXCLUDED_PATHSPECS,
178
+ ],
179
+ )
180
+ untracked_diffs = [
181
+ _build_untracked_diff(repo_root, rel_path)
182
+ for rel_path in untracked_output.splitlines()
183
+ if rel_path
184
+ ]
185
+
186
+ parts = [p for p in [tracked, staged, "\n".join(untracked_diffs)] if p]
187
+ return "\n".join(parts)
188
+ except RuntimeError as exc:
189
+ exit_with_error(f"Failed to collect git diff: {exc}")
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from git import exit_with_error
4
+
5
+ LINK_FILE_NAME = "link"
6
+
7
+
8
+ def save_link(assertion_dir: Path, url: str) -> None:
9
+ (assertion_dir / LINK_FILE_NAME).write_text(url + "\n", encoding="utf-8")
10
+
11
+
12
+ def load_link(assertion_dir: Path) -> str:
13
+ link_path = assertion_dir / LINK_FILE_NAME
14
+ if not link_path.exists():
15
+ exit_with_error(
16
+ "No session link found. Run `asrt verify` first to generate a link."
17
+ )
18
+ content = link_path.read_text(encoding="utf-8").strip()
19
+ if not content:
20
+ exit_with_error("Session link file is empty. Run `asrt verify` to regenerate.")
21
+ return content