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.
- assertion_cli-0.1.0/PKG-INFO +48 -0
- assertion_cli-0.1.0/README.md +34 -0
- assertion_cli-0.1.0/api.py +211 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/PKG-INFO +48 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/SOURCES.txt +28 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/dependency_links.txt +1 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/entry_points.txt +2 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/requires.txt +8 -0
- assertion_cli-0.1.0/assertion_cli.egg-info/top_level.txt +8 -0
- assertion_cli-0.1.0/bundle.py +26 -0
- assertion_cli-0.1.0/git.py +189 -0
- assertion_cli-0.1.0/link.py +21 -0
- assertion_cli-0.1.0/main.py +499 -0
- assertion_cli-0.1.0/models.py +86 -0
- assertion_cli-0.1.0/pyproject.toml +35 -0
- assertion_cli-0.1.0/session.py +220 -0
- assertion_cli-0.1.0/setup.cfg +4 -0
- assertion_cli-0.1.0/templates/ACTIVATION.md +14 -0
- assertion_cli-0.1.0/templates/SKILL.md +177 -0
- assertion_cli-0.1.0/templates/__init__.py +0 -0
- assertion_cli-0.1.0/tests/test_api.py +160 -0
- assertion_cli-0.1.0/tests/test_bundle.py +29 -0
- assertion_cli-0.1.0/tests/test_decision.py +34 -0
- assertion_cli-0.1.0/tests/test_git.py +190 -0
- assertion_cli-0.1.0/tests/test_init.py +130 -0
- assertion_cli-0.1.0/tests/test_link.py +31 -0
- assertion_cli-0.1.0/tests/test_main.py +23 -0
- assertion_cli-0.1.0/tests/test_prompt.py +68 -0
- assertion_cli-0.1.0/tests/test_session.py +132 -0
- assertion_cli-0.1.0/tests/test_stack_resolve.py +120 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|