assertion-cli 0.5.12__tar.gz → 0.5.14__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.5.12 → assertion_cli-0.5.14}/PKG-INFO +31 -4
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/README.md +30 -2
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/api.py +35 -18
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/PKG-INFO +31 -4
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/SOURCES.txt +3 -1
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/requires.txt +0 -1
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/top_level.txt +1 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/bundle.py +8 -11
- assertion_cli-0.5.14/credentials.py +120 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/git.py +114 -7
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/main.py +196 -62
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/models.py +7 -7
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/pyproject.toml +2 -3
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/session.py +20 -19
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/templates/SKILL.md +11 -16
- assertion_cli-0.5.14/tests/test_auth.py +129 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_bundle.py +10 -9
- assertion_cli-0.5.14/tests/test_credentials.py +122 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_git.py +282 -31
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_install.py +2 -5
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_session.py +9 -5
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_session_start.py +44 -10
- assertion_cli-0.5.12/tests/test_new.py +0 -142
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/link.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/setup.cfg +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/templates/__init__.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_api.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_link.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_main.py +0 -0
- {assertion_cli-0.5.12 → assertion_cli-0.5.14}/tests/test_prompt.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.14
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
Requires-Dist: httpx>=0.28.1
|
|
8
8
|
Requires-Dist: pydantic>=2.12.5
|
|
9
|
-
Requires-Dist: python-dotenv>=1.0
|
|
10
9
|
Requires-Dist: typer>=0.24.1
|
|
11
10
|
Provides-Extra: test
|
|
12
11
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
@@ -18,7 +17,8 @@ CLI for the Assertion API.
|
|
|
18
17
|
|
|
19
18
|
## Usage
|
|
20
19
|
|
|
21
|
-
The CLI
|
|
20
|
+
The CLI targets `https://app.tryassertion.com` by default; set
|
|
21
|
+
`ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
|
|
22
22
|
|
|
23
23
|
Run locally from the workspace:
|
|
24
24
|
|
|
@@ -34,12 +34,13 @@ uv tool install --reinstall assertion-cli # upgrade in place
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
The CLI package declares all of its direct runtime dependencies. At the moment
|
|
37
|
-
that set is `httpx`, `pydantic`,
|
|
37
|
+
that set is `httpx`, `pydantic`, and `typer`.
|
|
38
38
|
|
|
39
39
|
After installation:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
43
|
+
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
43
44
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
44
45
|
asrt new # reset local run state for a fresh session
|
|
45
46
|
asrt stacks
|
|
@@ -50,6 +51,32 @@ asrt verify # submit final verification (non-blocking
|
|
|
50
51
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
51
52
|
```
|
|
52
53
|
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{ "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
|
+
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
+
workspace before saving, so each repo can target a different workspace. Because
|
|
66
|
+
entries are keyed by path, moving or renaming the repo folder means logging in
|
|
67
|
+
again (which adds a new entry). `asrt logout` removes the current repo's entry;
|
|
68
|
+
`asrt whoami` shows which token is in effect.
|
|
69
|
+
|
|
70
|
+
Token resolution at call time, in order:
|
|
71
|
+
|
|
72
|
+
1. the entry for the current repo path in `~/.assertion/credentials.json`;
|
|
73
|
+
2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
|
+
with no remote that `asrt login` won't store);
|
|
75
|
+
|
|
76
|
+
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
77
|
+
defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
|
|
78
|
+
for a local backend.
|
|
79
|
+
|
|
53
80
|
## Publishing a new version
|
|
54
81
|
|
|
55
82
|
1. **Bump the version** in `cli/pyproject.toml`.
|
|
@@ -4,7 +4,8 @@ CLI for the Assertion API.
|
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
|
-
The CLI
|
|
7
|
+
The CLI targets `https://app.tryassertion.com` by default; set
|
|
8
|
+
`ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
|
|
8
9
|
|
|
9
10
|
Run locally from the workspace:
|
|
10
11
|
|
|
@@ -20,12 +21,13 @@ uv tool install --reinstall assertion-cli # upgrade in place
|
|
|
20
21
|
```
|
|
21
22
|
|
|
22
23
|
The CLI package declares all of its direct runtime dependencies. At the moment
|
|
23
|
-
that set is `httpx`, `pydantic`,
|
|
24
|
+
that set is `httpx`, `pydantic`, and `typer`.
|
|
24
25
|
|
|
25
26
|
After installation:
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
29
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
30
|
+
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
29
31
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
30
32
|
asrt new # reset local run state for a fresh session
|
|
31
33
|
asrt stacks
|
|
@@ -36,6 +38,32 @@ asrt verify # submit final verification (non-blocking
|
|
|
36
38
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
37
39
|
```
|
|
38
40
|
|
|
41
|
+
## Authentication
|
|
42
|
+
|
|
43
|
+
`asrt login` stores your API token in a global, user-level file —
|
|
44
|
+
`~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{ "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Run it from inside the repo it should authenticate. It validates the token and
|
|
51
|
+
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
52
|
+
workspace before saving, so each repo can target a different workspace. Because
|
|
53
|
+
entries are keyed by path, moving or renaming the repo folder means logging in
|
|
54
|
+
again (which adds a new entry). `asrt logout` removes the current repo's entry;
|
|
55
|
+
`asrt whoami` shows which token is in effect.
|
|
56
|
+
|
|
57
|
+
Token resolution at call time, in order:
|
|
58
|
+
|
|
59
|
+
1. the entry for the current repo path in `~/.assertion/credentials.json`;
|
|
60
|
+
2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
61
|
+
with no remote that `asrt login` won't store);
|
|
62
|
+
|
|
63
|
+
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
64
|
+
defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
|
|
65
|
+
for a local backend.
|
|
66
|
+
|
|
39
67
|
## Publishing a new version
|
|
40
68
|
|
|
41
69
|
1. **Bump the version** in `cli/pyproject.toml`.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
from typing import Any, TypeVar
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
8
|
import typer
|
|
8
|
-
from dotenv import find_dotenv, load_dotenv
|
|
9
9
|
from pydantic import BaseModel, ValidationError
|
|
10
10
|
|
|
11
|
+
from credentials import API_TOKEN_ENV, resolve_token
|
|
12
|
+
from git import find_git_root_or_none
|
|
11
13
|
from models import (
|
|
12
14
|
CheckpointResponse,
|
|
13
15
|
DecisionResponse,
|
|
@@ -24,28 +26,35 @@ T = TypeVar("T", bound=BaseModel)
|
|
|
24
26
|
# local backend (or self-hosted users) override with ASSERTION_BASE_URL.
|
|
25
27
|
DEFAULT_BASE_URL = "https://app.tryassertion.com"
|
|
26
28
|
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 300.0
|
|
27
|
-
API_TOKEN_ENV = "ASSERTION_API_TOKEN"
|
|
28
|
-
|
|
29
|
-
# Walk up from cwd looking for a .env so the user can drop their token in the
|
|
30
|
-
# repo root once and forget it. override=False means a real shell `export`
|
|
31
|
-
# still wins over the file — useful for CI.
|
|
32
|
-
_dotenv_path = find_dotenv(usecwd=True)
|
|
33
|
-
if _dotenv_path:
|
|
34
|
-
load_dotenv(_dotenv_path, override=False)
|
|
35
29
|
|
|
36
30
|
|
|
37
31
|
def _base_url() -> str:
|
|
38
32
|
return os.environ.get("ASSERTION_BASE_URL", DEFAULT_BASE_URL)
|
|
39
33
|
|
|
40
34
|
|
|
35
|
+
def _resolve_token() -> str | None:
|
|
36
|
+
"""Token for the current repo, else the shell env-var backup, else None.
|
|
37
|
+
|
|
38
|
+
The stored credential (keyed by the exact current git root) is primary; a
|
|
39
|
+
real `export ASSERTION_API_TOKEN` only fills in when no entry matches — e.g.
|
|
40
|
+
in CI, or a repo that was moved/renamed and never re-authenticated.
|
|
41
|
+
"""
|
|
42
|
+
token = resolve_token(find_git_root_or_none(Path.cwd()))
|
|
43
|
+
if token:
|
|
44
|
+
return token
|
|
45
|
+
env_token = (os.environ.get(API_TOKEN_ENV) or "").strip()
|
|
46
|
+
return env_token or None
|
|
47
|
+
|
|
48
|
+
|
|
41
49
|
def _auth_headers() -> dict[str, str]:
|
|
42
|
-
token = (
|
|
50
|
+
token = _resolve_token()
|
|
43
51
|
if not token:
|
|
52
|
+
repo_root = find_git_root_or_none(Path.cwd())
|
|
53
|
+
where = f" for {repo_root}" if repo_root else ""
|
|
44
54
|
typer.echo(
|
|
45
|
-
f"
|
|
46
|
-
"
|
|
47
|
-
f"
|
|
48
|
-
"(Or export it in your shell.)",
|
|
55
|
+
f"No saved Assertion token{where}. Run `asrt login` to authenticate "
|
|
56
|
+
"this repo (if you moved or renamed the folder, log in again).\n"
|
|
57
|
+
f"Alternatively, export {API_TOKEN_ENV} in your shell as a backup.",
|
|
49
58
|
err=True,
|
|
50
59
|
)
|
|
51
60
|
raise typer.Exit(code=1)
|
|
@@ -68,8 +77,16 @@ def _summarize_error(response: httpx.Response) -> str:
|
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
class AssertionClient:
|
|
71
|
-
def __init__(self) -> None:
|
|
80
|
+
def __init__(self, *, token: str | None = None) -> None:
|
|
72
81
|
self.base_url = _base_url()
|
|
82
|
+
# An explicit token lets `asrt login` validate a credential before it is
|
|
83
|
+
# saved; otherwise headers resolve from the store / env-var backup.
|
|
84
|
+
self._token_override = token.strip() if token else None
|
|
85
|
+
|
|
86
|
+
def _headers(self) -> dict[str, str]:
|
|
87
|
+
if self._token_override:
|
|
88
|
+
return {"Authorization": f"Bearer {self._token_override}"}
|
|
89
|
+
return _auth_headers()
|
|
73
90
|
|
|
74
91
|
def _request(
|
|
75
92
|
self,
|
|
@@ -81,7 +98,7 @@ class AssertionClient:
|
|
|
81
98
|
**kwargs: Any,
|
|
82
99
|
) -> T:
|
|
83
100
|
url = f"{self.base_url}{path}"
|
|
84
|
-
headers = {**
|
|
101
|
+
headers = {**self._headers(), **kwargs.pop("headers", {})}
|
|
85
102
|
try:
|
|
86
103
|
response = httpx.request(
|
|
87
104
|
method, url, timeout=timeout, headers=headers, **kwargs
|
|
@@ -119,7 +136,7 @@ class AssertionClient:
|
|
|
119
136
|
**kwargs: Any,
|
|
120
137
|
) -> DecisionResponse:
|
|
121
138
|
url = f"{self.base_url}{path}"
|
|
122
|
-
headers = {**
|
|
139
|
+
headers = {**self._headers(), **kwargs.pop("headers", {})}
|
|
123
140
|
try:
|
|
124
141
|
response = httpx.request(
|
|
125
142
|
method, url, timeout=timeout, headers=headers, **kwargs
|
|
@@ -145,7 +162,7 @@ class AssertionClient:
|
|
|
145
162
|
|
|
146
163
|
url = f"{self.base_url}/api/v0/agent/stacks"
|
|
147
164
|
try:
|
|
148
|
-
response = httpx.get(url, timeout=10.0, headers=
|
|
165
|
+
response = httpx.get(url, timeout=10.0, headers=self._headers())
|
|
149
166
|
response.raise_for_status()
|
|
150
167
|
except httpx.HTTPStatusError as exc:
|
|
151
168
|
summary = _summarize_error(exc.response)
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.14
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
Requires-Dist: httpx>=0.28.1
|
|
8
8
|
Requires-Dist: pydantic>=2.12.5
|
|
9
|
-
Requires-Dist: python-dotenv>=1.0
|
|
10
9
|
Requires-Dist: typer>=0.24.1
|
|
11
10
|
Provides-Extra: test
|
|
12
11
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
@@ -18,7 +17,8 @@ CLI for the Assertion API.
|
|
|
18
17
|
|
|
19
18
|
## Usage
|
|
20
19
|
|
|
21
|
-
The CLI
|
|
20
|
+
The CLI targets `https://app.tryassertion.com` by default; set
|
|
21
|
+
`ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
|
|
22
22
|
|
|
23
23
|
Run locally from the workspace:
|
|
24
24
|
|
|
@@ -34,12 +34,13 @@ uv tool install --reinstall assertion-cli # upgrade in place
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
The CLI package declares all of its direct runtime dependencies. At the moment
|
|
37
|
-
that set is `httpx`, `pydantic`,
|
|
37
|
+
that set is `httpx`, `pydantic`, and `typer`.
|
|
38
38
|
|
|
39
39
|
After installation:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
43
|
+
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
43
44
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
44
45
|
asrt new # reset local run state for a fresh session
|
|
45
46
|
asrt stacks
|
|
@@ -50,6 +51,32 @@ asrt verify # submit final verification (non-blocking
|
|
|
50
51
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
51
52
|
```
|
|
52
53
|
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{ "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
|
+
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
+
workspace before saving, so each repo can target a different workspace. Because
|
|
66
|
+
entries are keyed by path, moving or renaming the repo folder means logging in
|
|
67
|
+
again (which adds a new entry). `asrt logout` removes the current repo's entry;
|
|
68
|
+
`asrt whoami` shows which token is in effect.
|
|
69
|
+
|
|
70
|
+
Token resolution at call time, in order:
|
|
71
|
+
|
|
72
|
+
1. the entry for the current repo path in `~/.assertion/credentials.json`;
|
|
73
|
+
2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
|
+
with no remote that `asrt login` won't store);
|
|
75
|
+
|
|
76
|
+
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
77
|
+
defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
|
|
78
|
+
for a local backend.
|
|
79
|
+
|
|
53
80
|
## Publishing a new version
|
|
54
81
|
|
|
55
82
|
1. **Bump the version** in `cli/pyproject.toml`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
api.py
|
|
3
3
|
bundle.py
|
|
4
|
+
credentials.py
|
|
4
5
|
git.py
|
|
5
6
|
link.py
|
|
6
7
|
main.py
|
|
@@ -17,13 +18,14 @@ templates/AGENTS.md
|
|
|
17
18
|
templates/SKILL.md
|
|
18
19
|
templates/__init__.py
|
|
19
20
|
tests/test_api.py
|
|
21
|
+
tests/test_auth.py
|
|
20
22
|
tests/test_bundle.py
|
|
23
|
+
tests/test_credentials.py
|
|
21
24
|
tests/test_decision.py
|
|
22
25
|
tests/test_git.py
|
|
23
26
|
tests/test_install.py
|
|
24
27
|
tests/test_link.py
|
|
25
28
|
tests/test_main.py
|
|
26
|
-
tests/test_new.py
|
|
27
29
|
tests/test_prompt.py
|
|
28
30
|
tests/test_session.py
|
|
29
31
|
tests/test_session_start.py
|
|
@@ -6,24 +6,21 @@ from models import MetadataPayload
|
|
|
6
6
|
|
|
7
7
|
ASSERTION_DIR_NAME = ".assertion"
|
|
8
8
|
DIFF_ARCHIVE_PATH = "git.diff"
|
|
9
|
-
|
|
9
|
+
REVIEW_DIFF_ARCHIVE_PATH = "review.diff"
|
|
10
10
|
METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
|
|
11
11
|
PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
|
|
12
12
|
CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _metadata_wire_json(metadata: MetadataPayload) -> str:
|
|
16
|
-
"""Serialize metadata for the bundle
|
|
16
|
+
"""Serialize metadata for the bundle.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
that SHA literally is the clone's HEAD. Same value, different name per
|
|
22
|
-
side; the remap lives here so neither side has to know about the other's
|
|
23
|
-
perspective.
|
|
18
|
+
`clone_sha` is the remote commit the backend sandbox checks out, so it is
|
|
19
|
+
emitted as `head_sha` on the wire. `base_sha` is the verification base
|
|
20
|
+
(where the user wants to start verifying from) and is emitted as-is.
|
|
24
21
|
"""
|
|
25
22
|
payload = metadata.model_dump()
|
|
26
|
-
payload["head_sha"] = payload.pop("
|
|
23
|
+
payload["head_sha"] = payload.pop("clone_sha", None)
|
|
27
24
|
return json.dumps(payload, indent=2) + "\n"
|
|
28
25
|
|
|
29
26
|
|
|
@@ -31,7 +28,7 @@ def build_bundle(
|
|
|
31
28
|
*,
|
|
32
29
|
metadata: MetadataPayload,
|
|
33
30
|
diff_text: str,
|
|
34
|
-
|
|
31
|
+
review_diff_text: str,
|
|
35
32
|
prompts_text: str,
|
|
36
33
|
checkpoint_text: str,
|
|
37
34
|
) -> bytes:
|
|
@@ -41,5 +38,5 @@ def build_bundle(
|
|
|
41
38
|
zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
|
|
42
39
|
zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
|
|
43
40
|
zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
|
|
44
|
-
zf.writestr(
|
|
41
|
+
zf.writestr(REVIEW_DIFF_ARCHIVE_PATH, review_diff_text)
|
|
45
42
|
return buf.getvalue()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Global, user-level token store at ``~/.assertion/credentials.json``.
|
|
2
|
+
|
|
3
|
+
The store is a map of canonicalized git-root path -> API token, so each repo
|
|
4
|
+
resolves to its own workspace token:
|
|
5
|
+
|
|
6
|
+
{"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
|
|
7
|
+
|
|
8
|
+
This module is deliberately git-free and pure stdlib — callers pass in the repo
|
|
9
|
+
root (resolved via ``git.py``) and handle their own error reporting. Token
|
|
10
|
+
resolution precedence (repo entry first, then the env-var backup) lives in
|
|
11
|
+
``api.py``; this module only owns on-disk reads and writes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
API_TOKEN_ENV = "ASSERTION_API_TOKEN"
|
|
21
|
+
|
|
22
|
+
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
23
|
+
CREDENTIALS_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def assertion_home() -> Path:
|
|
27
|
+
"""Directory holding global Assertion credentials (``~/.assertion``)."""
|
|
28
|
+
return Path.home() / ".assertion"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def credentials_path() -> Path:
|
|
32
|
+
return assertion_home() / CREDENTIALS_FILE_NAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _empty_store() -> dict:
|
|
36
|
+
return {"version": CREDENTIALS_VERSION, "repos": {}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_store() -> dict:
|
|
40
|
+
"""Return the credentials store, tolerating a missing or corrupt file.
|
|
41
|
+
|
|
42
|
+
A malformed file is treated as empty rather than crashing every command —
|
|
43
|
+
the user can re-run ``asrt login`` to repair it.
|
|
44
|
+
"""
|
|
45
|
+
path = credentials_path()
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return _empty_store()
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
51
|
+
return _empty_store()
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
return _empty_store()
|
|
54
|
+
data.setdefault("version", CREDENTIALS_VERSION)
|
|
55
|
+
if not isinstance(data.get("repos"), dict):
|
|
56
|
+
data["repos"] = {}
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _repo_key(repo_root: Path) -> str:
|
|
61
|
+
"""Canonical store key for a repo root (absolute, symlinks resolved)."""
|
|
62
|
+
return str(repo_root.resolve())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_store(store: dict) -> Path:
|
|
66
|
+
home = assertion_home()
|
|
67
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
try:
|
|
69
|
+
home.chmod(0o700)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
path = credentials_path()
|
|
73
|
+
path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
|
|
74
|
+
try:
|
|
75
|
+
path.chmod(0o600)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_token(token: str, *, repo_root: Path) -> Path:
|
|
82
|
+
"""Persist ``token`` for ``repo_root`` and return the credentials file path."""
|
|
83
|
+
store = load_store()
|
|
84
|
+
store["repos"][_repo_key(repo_root)] = token
|
|
85
|
+
return _write_store(store)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def remove_token(*, repo_root: Path) -> bool:
|
|
89
|
+
"""Delete the entry for ``repo_root``. Return ``True`` if one was removed."""
|
|
90
|
+
store = load_store()
|
|
91
|
+
key = _repo_key(repo_root)
|
|
92
|
+
if key in store["repos"]:
|
|
93
|
+
del store["repos"][key]
|
|
94
|
+
_write_store(store)
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_token(repo_root: Path | None) -> str | None:
|
|
100
|
+
"""Return the stored token for ``repo_root`` (exact path match), else ``None``.
|
|
101
|
+
|
|
102
|
+
No fuzzy or parent-path matching: a moved or never-authenticated repo simply
|
|
103
|
+
misses, and the caller falls back to the env-var backup.
|
|
104
|
+
"""
|
|
105
|
+
if repo_root is None:
|
|
106
|
+
return None
|
|
107
|
+
token = load_store()["repos"].get(_repo_key(repo_root))
|
|
108
|
+
return token or None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_source(repo_root: Path | None) -> str | None:
|
|
112
|
+
"""Describe where the active token comes from, in resolution order.
|
|
113
|
+
|
|
114
|
+
Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
|
|
115
|
+
"""
|
|
116
|
+
if repo_root is not None and resolve_token(repo_root) is not None:
|
|
117
|
+
return f"the saved token for {_repo_key(repo_root)}"
|
|
118
|
+
if (os.environ.get(API_TOKEN_ENV) or "").strip():
|
|
119
|
+
return f"the {API_TOKEN_ENV} environment variable"
|
|
120
|
+
return None
|
|
@@ -26,7 +26,12 @@ def run_git_command(
|
|
|
26
26
|
return completed.stdout if preserve_stdout else completed.stdout.strip()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def
|
|
29
|
+
def find_git_root_or_none(start_path: Path) -> Path | None:
|
|
30
|
+
"""Return the repo root for *start_path*, or ``None`` if it isn't in a repo.
|
|
31
|
+
|
|
32
|
+
Token resolution must degrade gracefully (to the env-var backup) outside a
|
|
33
|
+
git repo, so it needs a non-fatal variant of ``find_git_root``.
|
|
34
|
+
"""
|
|
30
35
|
completed = subprocess.run(
|
|
31
36
|
["git", "rev-parse", "--show-toplevel"],
|
|
32
37
|
cwd=start_path,
|
|
@@ -35,10 +40,61 @@ def find_git_root(start_path: Path) -> Path:
|
|
|
35
40
|
check=False,
|
|
36
41
|
)
|
|
37
42
|
if completed.returncode != 0:
|
|
38
|
-
|
|
43
|
+
return None
|
|
39
44
|
return Path(completed.stdout.strip())
|
|
40
45
|
|
|
41
46
|
|
|
47
|
+
def find_git_root(start_path: Path) -> Path:
|
|
48
|
+
root = find_git_root_or_none(start_path)
|
|
49
|
+
if root is None:
|
|
50
|
+
exit_with_error("Current directory is not inside a git repository.")
|
|
51
|
+
return root
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_github_full_name(url: str) -> str | None:
|
|
55
|
+
"""Reduce a git remote URL to its GitHub ``owner/repo`` full name.
|
|
56
|
+
|
|
57
|
+
Handles the three common origin forms and returns ``None`` when the URL has
|
|
58
|
+
no ``owner/repo`` tail:
|
|
59
|
+
|
|
60
|
+
git@github.com:acme/mvp.git -> acme/mvp
|
|
61
|
+
https://github.com/acme/mvp.git -> acme/mvp
|
|
62
|
+
ssh://git@github.com/acme/mvp.git -> acme/mvp
|
|
63
|
+
"""
|
|
64
|
+
text = url.strip()
|
|
65
|
+
if not text:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if "://" in text:
|
|
69
|
+
# scheme://[user@]host/owner/repo — drop scheme + host.
|
|
70
|
+
path = text.split("://", 1)[1]
|
|
71
|
+
path = path.split("/", 1)[1] if "/" in path else ""
|
|
72
|
+
elif ":" in text and ("/" not in text or text.index(":") < text.index("/")):
|
|
73
|
+
# scp-like: [user@]host:owner/repo (colon precedes any path slash).
|
|
74
|
+
path = text.split(":", 1)[1]
|
|
75
|
+
else:
|
|
76
|
+
path = text
|
|
77
|
+
|
|
78
|
+
path = path.removesuffix(".git").strip("/")
|
|
79
|
+
parts = [p for p in path.split("/") if p]
|
|
80
|
+
if len(parts) < 2:
|
|
81
|
+
return None
|
|
82
|
+
return f"{parts[-2]}/{parts[-1]}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def current_repo_full_name(repo_root: Path) -> str | None:
|
|
86
|
+
"""Return the repo's GitHub ``owner/repo``, or ``None`` if it has no origin.
|
|
87
|
+
|
|
88
|
+
Used by ``asrt login`` to match the current checkout against the workspace's
|
|
89
|
+
stacks (whose ``repo`` field is the GitHub full name).
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
url = run_git_command(repo_root, ["remote", "get-url", "origin"])
|
|
93
|
+
except RuntimeError:
|
|
94
|
+
return None
|
|
95
|
+
return _parse_github_full_name(url)
|
|
96
|
+
|
|
97
|
+
|
|
42
98
|
def get_head_sha(repo_root: Path) -> str:
|
|
43
99
|
try:
|
|
44
100
|
return run_git_command(repo_root, ["rev-parse", "HEAD"])
|
|
@@ -59,7 +115,8 @@ def get_upstream_ref(repo_root: Path) -> str | None:
|
|
|
59
115
|
# 1. Configured upstream (git branch -u).
|
|
60
116
|
try:
|
|
61
117
|
return run_git_command(
|
|
62
|
-
repo_root,
|
|
118
|
+
repo_root,
|
|
119
|
+
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
63
120
|
)
|
|
64
121
|
except RuntimeError:
|
|
65
122
|
pass
|
|
@@ -83,15 +140,16 @@ def get_upstream_ref(repo_root: Path) -> str | None:
|
|
|
83
140
|
merged = run_git_command(
|
|
84
141
|
repo_root,
|
|
85
142
|
[
|
|
86
|
-
"branch",
|
|
143
|
+
"branch",
|
|
144
|
+
"-r",
|
|
145
|
+
"--merged",
|
|
146
|
+
"HEAD",
|
|
87
147
|
"--format=%(refname:short)",
|
|
88
148
|
],
|
|
89
149
|
)
|
|
90
150
|
ancestors = [r for r in merged.splitlines() if r]
|
|
91
151
|
if ancestors:
|
|
92
|
-
return run_git_command(
|
|
93
|
-
repo_root, ["merge-base", "HEAD", *ancestors]
|
|
94
|
-
)
|
|
152
|
+
return run_git_command(repo_root, ["merge-base", "HEAD", *ancestors])
|
|
95
153
|
except RuntimeError:
|
|
96
154
|
pass
|
|
97
155
|
|
|
@@ -132,6 +190,55 @@ def get_merge_base(repo_root: Path, upstream: str) -> str:
|
|
|
132
190
|
)
|
|
133
191
|
|
|
134
192
|
|
|
193
|
+
def is_ancestor(repo_root: Path, maybe_ancestor: str, descendant: str) -> bool:
|
|
194
|
+
"""Return True if *maybe_ancestor* is an ancestor of *descendant*."""
|
|
195
|
+
try:
|
|
196
|
+
run_git_command(
|
|
197
|
+
repo_root, ["merge-base", "--is-ancestor", maybe_ancestor, descendant]
|
|
198
|
+
)
|
|
199
|
+
return True
|
|
200
|
+
except RuntimeError:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_remote_ancestor(repo_root: Path, sha: str) -> str | None:
|
|
205
|
+
"""Return the newest remote commit that is an ancestor of *sha*.
|
|
206
|
+
|
|
207
|
+
Uses local remote-tracking refs so this works offline. Returns ``None``
|
|
208
|
+
when no remote-tracking ref contains an ancestor of *sha*.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
merged = run_git_command(
|
|
212
|
+
repo_root,
|
|
213
|
+
["branch", "-r", "--merged", sha, "--format=%(refname:short)"],
|
|
214
|
+
)
|
|
215
|
+
except RuntimeError:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
candidates = [r for r in merged.splitlines() if r]
|
|
219
|
+
if not candidates:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
newest_ref: str | None = None
|
|
223
|
+
newest_ts = -1
|
|
224
|
+
for ref in candidates:
|
|
225
|
+
try:
|
|
226
|
+
ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", ref]))
|
|
227
|
+
except (RuntimeError, ValueError):
|
|
228
|
+
continue
|
|
229
|
+
if ts > newest_ts:
|
|
230
|
+
newest_ts = ts
|
|
231
|
+
newest_ref = ref
|
|
232
|
+
|
|
233
|
+
if newest_ref is None:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
return run_git_command(repo_root, ["merge-base", sha, newest_ref])
|
|
238
|
+
except RuntimeError:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
135
242
|
def get_head_branch(repo_root: Path) -> str | None:
|
|
136
243
|
"""Return the current branch name, or None if HEAD is detached."""
|
|
137
244
|
try:
|