assertion-cli 0.5.16__tar.gz → 0.5.18__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.16 → assertion_cli-0.5.18}/PKG-INFO +14 -9
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/README.md +13 -8
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/api.py +10 -6
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/PKG-INFO +14 -9
- assertion_cli-0.5.18/credentials.py +253 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/main.py +45 -18
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/pyproject.toml +1 -1
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/SKILL.md +2 -2
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_auth.py +5 -1
- assertion_cli-0.5.18/tests/test_credentials.py +189 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_verify.py +78 -76
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_verify_integration.py +45 -0
- assertion_cli-0.5.16/credentials.py +0 -120
- assertion_cli-0.5.16/tests/test_credentials.py +0 -122
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/SOURCES.txt +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/bundle.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/git.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/link.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/models.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/session.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/setup.cfg +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/__init__.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_api.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_git.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_install.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_link.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_main.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_session.py +0 -0
- {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_session_start.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.18
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
54
54
|
## Authentication
|
|
55
55
|
|
|
56
56
|
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
|
-
{
|
|
60
|
+
{
|
|
61
|
+
"version": 2,
|
|
62
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
63
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
64
|
+
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
68
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
-
workspace before saving
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
70
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
71
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
72
|
+
shows which token is in effect.
|
|
69
73
|
|
|
70
74
|
Token resolution at call time, in order:
|
|
71
75
|
|
|
72
|
-
1. the
|
|
73
|
-
2.
|
|
76
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
77
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
78
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
79
|
with no remote that `asrt login` won't store);
|
|
75
80
|
|
|
76
81
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -41,23 +41,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
41
41
|
## Authentication
|
|
42
42
|
|
|
43
43
|
`asrt login` stores your API token in a global, user-level file —
|
|
44
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
44
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
45
45
|
|
|
46
46
|
```json
|
|
47
|
-
{
|
|
47
|
+
{
|
|
48
|
+
"version": 2,
|
|
49
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
50
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
51
|
+
}
|
|
48
52
|
```
|
|
49
53
|
|
|
50
54
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
51
55
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
52
|
-
workspace before saving
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
57
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
58
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
59
|
+
shows which token is in effect.
|
|
56
60
|
|
|
57
61
|
Token resolution at call time, in order:
|
|
58
62
|
|
|
59
|
-
1. the
|
|
60
|
-
2.
|
|
63
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
64
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
65
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
61
66
|
with no remote that `asrt login` won't store);
|
|
62
67
|
|
|
63
68
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -9,7 +9,7 @@ import typer
|
|
|
9
9
|
from pydantic import BaseModel, ValidationError
|
|
10
10
|
|
|
11
11
|
from credentials import API_TOKEN_ENV, resolve_token
|
|
12
|
-
from git import find_git_root_or_none
|
|
12
|
+
from git import current_repo_full_name, find_git_root_or_none
|
|
13
13
|
from models import (
|
|
14
14
|
CheckpointResponse,
|
|
15
15
|
DecisionResponse,
|
|
@@ -35,11 +35,15 @@ def _base_url() -> str:
|
|
|
35
35
|
def _resolve_token() -> str | None:
|
|
36
36
|
"""Token for the current repo, else the shell env-var backup, else None.
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Stored credentials are primarily keyed by the current repo's GitHub
|
|
39
|
+
``owner/repo`` identity, with the legacy exact git-root path as fallback. A
|
|
40
|
+
real `export ASSERTION_API_TOKEN` only fills in when no stored entry matches.
|
|
41
41
|
"""
|
|
42
|
-
|
|
42
|
+
repo_root = find_git_root_or_none(Path.cwd())
|
|
43
|
+
repo_full_name = (
|
|
44
|
+
current_repo_full_name(repo_root) if repo_root is not None else None
|
|
45
|
+
)
|
|
46
|
+
token = resolve_token(repo_root, repo_full_name)
|
|
43
47
|
if token:
|
|
44
48
|
return token
|
|
45
49
|
env_token = (os.environ.get(API_TOKEN_ENV) or "").strip()
|
|
@@ -53,7 +57,7 @@ def _auth_headers() -> dict[str, str]:
|
|
|
53
57
|
where = f" for {repo_root}" if repo_root else ""
|
|
54
58
|
typer.echo(
|
|
55
59
|
f"No saved Assertion token{where}. Run `asrt login` to authenticate "
|
|
56
|
-
"this repo
|
|
60
|
+
"this repo.\n"
|
|
57
61
|
f"Alternatively, export {API_TOKEN_ENV} in your shell as a backup.",
|
|
58
62
|
err=True,
|
|
59
63
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.18
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
54
54
|
## Authentication
|
|
55
55
|
|
|
56
56
|
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
|
-
{
|
|
60
|
+
{
|
|
61
|
+
"version": 2,
|
|
62
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
63
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
64
|
+
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
68
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
-
workspace before saving
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
70
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
71
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
72
|
+
shows which token is in effect.
|
|
69
73
|
|
|
70
74
|
Token resolution at call time, in order:
|
|
71
75
|
|
|
72
|
-
1. the
|
|
73
|
-
2.
|
|
76
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
77
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
78
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
79
|
with no remote that `asrt login` won't store);
|
|
75
80
|
|
|
76
81
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Global, user-level token store at ``~/.assertion/credentials.json``.
|
|
2
|
+
|
|
3
|
+
The current store keeps plaintext tokens plus an inverted GitHub repo index, so
|
|
4
|
+
reclones or moved folders keep resolving as long as their ``origin`` still
|
|
5
|
+
points at a connected repo:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"version": 2,
|
|
9
|
+
"tokens": {"alk_...": ["acme/mvp"]},
|
|
10
|
+
"repo_index": {"acme/mvp": "alk_..."}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
The legacy v1 ``repos`` path map is still read as a fallback:
|
|
14
|
+
|
|
15
|
+
{"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
|
|
16
|
+
|
|
17
|
+
This module is deliberately git-free and pure stdlib — callers pass in the repo
|
|
18
|
+
root / repo full name (resolved via ``git.py``) and handle their own error
|
|
19
|
+
reporting. Token resolution precedence (repo entry first, then the env-var
|
|
20
|
+
backup) lives in ``api.py``; this module only owns on-disk reads and writes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
API_TOKEN_ENV = "ASSERTION_API_TOKEN"
|
|
30
|
+
|
|
31
|
+
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
32
|
+
CREDENTIALS_VERSION = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assertion_home() -> Path:
|
|
36
|
+
"""Directory holding global Assertion credentials (``~/.assertion``)."""
|
|
37
|
+
return Path.home() / ".assertion"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def credentials_path() -> Path:
|
|
41
|
+
return assertion_home() / CREDENTIALS_FILE_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _empty_store() -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"version": CREDENTIALS_VERSION,
|
|
47
|
+
"tokens": {},
|
|
48
|
+
"repo_index": {},
|
|
49
|
+
"repos": {},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_store() -> dict:
|
|
54
|
+
"""Return the credentials store, tolerating a missing or corrupt file.
|
|
55
|
+
|
|
56
|
+
A malformed file is treated as empty rather than crashing every command —
|
|
57
|
+
the user can re-run ``asrt login`` to repair it.
|
|
58
|
+
"""
|
|
59
|
+
path = credentials_path()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return _empty_store()
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
65
|
+
return _empty_store()
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
return _empty_store()
|
|
68
|
+
data.setdefault("version", CREDENTIALS_VERSION)
|
|
69
|
+
if not isinstance(data.get("tokens"), dict):
|
|
70
|
+
data["tokens"] = {}
|
|
71
|
+
if not isinstance(data.get("repo_index"), dict):
|
|
72
|
+
data["repo_index"] = _repo_index_from_tokens(data["tokens"])
|
|
73
|
+
if not isinstance(data.get("repos"), dict):
|
|
74
|
+
data["repos"] = {}
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _repo_key(repo_root: Path) -> str:
|
|
79
|
+
"""Canonical store key for a repo root (absolute, symlinks resolved)."""
|
|
80
|
+
return str(repo_root.resolve())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _repo_name_key(repo_full_name: str) -> str:
|
|
84
|
+
return repo_full_name.strip().lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalize_repo_names(repo_full_names: list[str]) -> list[str]:
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
normalized: list[str] = []
|
|
90
|
+
for repo in repo_full_names:
|
|
91
|
+
key = _repo_name_key(repo)
|
|
92
|
+
if not key or key in seen:
|
|
93
|
+
continue
|
|
94
|
+
seen.add(key)
|
|
95
|
+
normalized.append(key)
|
|
96
|
+
return normalized
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _repo_index_from_tokens(tokens: dict) -> dict[str, str]:
|
|
100
|
+
index: dict[str, str] = {}
|
|
101
|
+
for token, repos in tokens.items():
|
|
102
|
+
if not isinstance(token, str) or not isinstance(repos, list):
|
|
103
|
+
continue
|
|
104
|
+
for repo in repos:
|
|
105
|
+
key = _repo_name_key(str(repo))
|
|
106
|
+
if key:
|
|
107
|
+
index[key] = token
|
|
108
|
+
return index
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _write_store(store: dict) -> Path:
|
|
112
|
+
home = assertion_home()
|
|
113
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
try:
|
|
115
|
+
home.chmod(0o700)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
path = credentials_path()
|
|
119
|
+
path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
|
|
120
|
+
try:
|
|
121
|
+
path.chmod(0o600)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_token(
|
|
128
|
+
token: str, *, repo_root: Path, repo_full_names: list[str] | None = None
|
|
129
|
+
) -> Path:
|
|
130
|
+
"""Persist ``token`` and return the credentials file path.
|
|
131
|
+
|
|
132
|
+
``repo_full_names`` are canonical GitHub ``owner/repo`` names visible to the
|
|
133
|
+
token. The legacy path entry is still written so older CLIs can read the
|
|
134
|
+
file during a rolling upgrade.
|
|
135
|
+
"""
|
|
136
|
+
store = load_store()
|
|
137
|
+
if repo_full_names is not None:
|
|
138
|
+
normalized = _normalize_repo_names(repo_full_names)
|
|
139
|
+
normalized_set = set(normalized)
|
|
140
|
+
repo_index = store["repo_index"]
|
|
141
|
+
for repo, indexed_token in list(repo_index.items()):
|
|
142
|
+
if indexed_token == token or repo in normalized_set:
|
|
143
|
+
del repo_index[repo]
|
|
144
|
+
for saved_token, repos in list(store["tokens"].items()):
|
|
145
|
+
if saved_token == token or not isinstance(repos, list):
|
|
146
|
+
continue
|
|
147
|
+
filtered = [
|
|
148
|
+
_repo_name_key(str(repo))
|
|
149
|
+
for repo in repos
|
|
150
|
+
if _repo_name_key(str(repo)) not in normalized_set
|
|
151
|
+
]
|
|
152
|
+
if filtered:
|
|
153
|
+
store["tokens"][saved_token] = filtered
|
|
154
|
+
else:
|
|
155
|
+
del store["tokens"][saved_token]
|
|
156
|
+
for repo in normalized:
|
|
157
|
+
if repo_index.get(repo) == saved_token:
|
|
158
|
+
del repo_index[repo]
|
|
159
|
+
store["tokens"][token] = normalized
|
|
160
|
+
for repo in normalized:
|
|
161
|
+
repo_index[repo] = token
|
|
162
|
+
store["repos"][_repo_key(repo_root)] = token
|
|
163
|
+
return _write_store(store)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def remove_token(*, repo_root: Path, repo_full_name: str | None = None) -> bool:
|
|
167
|
+
"""Delete this repo's saved credential. Return ``True`` if one was removed."""
|
|
168
|
+
store = load_store()
|
|
169
|
+
key = _repo_key(repo_root)
|
|
170
|
+
token = store["repos"].pop(key, None)
|
|
171
|
+
removed = token is not None
|
|
172
|
+
if repo_full_name is not None:
|
|
173
|
+
repo_key = _repo_name_key(repo_full_name)
|
|
174
|
+
indexed_token = store["repo_index"].pop(repo_key, None)
|
|
175
|
+
target_token = token or indexed_token
|
|
176
|
+
for saved_token, repos in list(store["tokens"].items()):
|
|
177
|
+
if target_token is not None and saved_token != target_token:
|
|
178
|
+
continue
|
|
179
|
+
if not isinstance(repos, list):
|
|
180
|
+
continue
|
|
181
|
+
filtered = [
|
|
182
|
+
str(repo).strip().lower()
|
|
183
|
+
for repo in repos
|
|
184
|
+
if _repo_name_key(str(repo)) != repo_key
|
|
185
|
+
]
|
|
186
|
+
if len(filtered) != len(repos):
|
|
187
|
+
removed = True
|
|
188
|
+
if filtered:
|
|
189
|
+
store["tokens"][saved_token] = filtered
|
|
190
|
+
else:
|
|
191
|
+
del store["tokens"][saved_token]
|
|
192
|
+
if saved_token == target_token:
|
|
193
|
+
break
|
|
194
|
+
removed = removed or indexed_token is not None
|
|
195
|
+
if token:
|
|
196
|
+
repo_refs = 0
|
|
197
|
+
for saved in store["repos"].values():
|
|
198
|
+
if saved == token:
|
|
199
|
+
repo_refs += 1
|
|
200
|
+
indexed_refs = [
|
|
201
|
+
indexed_token
|
|
202
|
+
for indexed_token in store["repo_index"].values()
|
|
203
|
+
if indexed_token == token
|
|
204
|
+
]
|
|
205
|
+
if (
|
|
206
|
+
repo_refs == 0
|
|
207
|
+
and not indexed_refs
|
|
208
|
+
and token in store["tokens"]
|
|
209
|
+
and repo_full_name is None
|
|
210
|
+
):
|
|
211
|
+
del store["tokens"][token]
|
|
212
|
+
if removed:
|
|
213
|
+
_write_store(store)
|
|
214
|
+
return removed
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_token(
|
|
218
|
+
repo_root: Path | None, repo_full_name: str | None = None
|
|
219
|
+
) -> str | None:
|
|
220
|
+
"""Return the stored token for ``repo_full_name`` / ``repo_root``.
|
|
221
|
+
|
|
222
|
+
Repo-name matching is primary and survives reclones/moved folders. The
|
|
223
|
+
legacy exact path match is retained so existing credentials keep working.
|
|
224
|
+
"""
|
|
225
|
+
store = load_store()
|
|
226
|
+
if repo_full_name is not None:
|
|
227
|
+
local = _repo_name_key(repo_full_name)
|
|
228
|
+
token = store["repo_index"].get(local)
|
|
229
|
+
if token:
|
|
230
|
+
return token
|
|
231
|
+
if repo_root is None:
|
|
232
|
+
return None
|
|
233
|
+
token = store["repos"].get(_repo_key(repo_root))
|
|
234
|
+
return token or None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def describe_source(
|
|
238
|
+
repo_root: Path | None, repo_full_name: str | None = None
|
|
239
|
+
) -> str | None:
|
|
240
|
+
"""Describe where the active token comes from, in resolution order.
|
|
241
|
+
|
|
242
|
+
Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
|
|
243
|
+
"""
|
|
244
|
+
if (
|
|
245
|
+
repo_full_name is not None
|
|
246
|
+
and resolve_token(repo_root, repo_full_name) is not None
|
|
247
|
+
):
|
|
248
|
+
return f"the saved token for {repo_full_name.strip().lower()}"
|
|
249
|
+
if repo_root is not None and resolve_token(repo_root) is not None:
|
|
250
|
+
return f"the saved token for {_repo_key(repo_root)}"
|
|
251
|
+
if (os.environ.get(API_TOKEN_ENV) or "").strip():
|
|
252
|
+
return f"the {API_TOKEN_ENV} environment variable"
|
|
253
|
+
return None
|
|
@@ -125,8 +125,9 @@ def login(
|
|
|
125
125
|
"""Authenticate this repo and save its token under ~/.assertion/.
|
|
126
126
|
|
|
127
127
|
Generate a token at Settings → Authentication in the dashboard, then run
|
|
128
|
-
this from inside
|
|
129
|
-
|
|
128
|
+
this from inside any repo it can access. The token is stored with the
|
|
129
|
+
workspace's stack repos, so moved folders and fresh clones keep working when
|
|
130
|
+
their `origin` remote matches.
|
|
130
131
|
|
|
131
132
|
Only repos whose `origin` remote matches a stack in the token's workspace
|
|
132
133
|
are stored; a repo with no remote can't be verified, so use the
|
|
@@ -173,7 +174,9 @@ def login(
|
|
|
173
174
|
"the dashboard, then re-run `asrt login`."
|
|
174
175
|
)
|
|
175
176
|
|
|
176
|
-
path = save_token(
|
|
177
|
+
path = save_token(
|
|
178
|
+
token, repo_root=repo_root, repo_full_names=[s.repo for s in stack_list]
|
|
179
|
+
)
|
|
177
180
|
names = ", ".join(f"'{s.name}'" for s in matched)
|
|
178
181
|
typer.echo(f"✓ {local} is connected to stack(s): {names}")
|
|
179
182
|
typer.echo(f"Saved token for {repo_root} to {path}")
|
|
@@ -186,7 +189,8 @@ def logout() -> None:
|
|
|
186
189
|
if repo_root is None:
|
|
187
190
|
exit_with_error("Not inside a git repository.")
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
local = current_repo_full_name(repo_root)
|
|
193
|
+
if remove_token(repo_root=repo_root, repo_full_name=local):
|
|
190
194
|
typer.echo(f"Removed saved token for {repo_root}.")
|
|
191
195
|
else:
|
|
192
196
|
typer.echo(f"No saved token for {repo_root}.")
|
|
@@ -196,7 +200,8 @@ def logout() -> None:
|
|
|
196
200
|
def whoami() -> None:
|
|
197
201
|
"""Show which token would be used here and confirm it works."""
|
|
198
202
|
repo_root = find_git_root_or_none(Path.cwd())
|
|
199
|
-
|
|
203
|
+
local = current_repo_full_name(repo_root) if repo_root is not None else None
|
|
204
|
+
source = describe_source(repo_root, local)
|
|
200
205
|
if source is None:
|
|
201
206
|
exit_with_error(
|
|
202
207
|
"Not authenticated. Run `asrt login` to save a token for this repo."
|
|
@@ -694,22 +699,36 @@ def _build_verify_bundle(
|
|
|
694
699
|
return bundle_bytes, review_diff_text, compute_review_diff_hash(review_diff_text)
|
|
695
700
|
|
|
696
701
|
|
|
697
|
-
def
|
|
702
|
+
def _compute_review_diff_fingerprint(ctx) -> tuple[str, str]:
|
|
703
|
+
"""Return (review_diff_text, review_diff_hash) without bundle side effects."""
|
|
704
|
+
verify_base = read_init_verify_base_sha(ctx.assertion_dir)
|
|
705
|
+
review_diff_text = get_full_diff(ctx.repo_root, verify_base)
|
|
706
|
+
return review_diff_text, compute_review_diff_hash(review_diff_text)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _try_reuse_verify(
|
|
698
710
|
assertion_dir: Path,
|
|
699
711
|
*,
|
|
700
712
|
review_diff_hash: str,
|
|
701
713
|
json_output: bool,
|
|
702
714
|
) -> bool:
|
|
703
|
-
"""Reuse
|
|
715
|
+
"""Reuse a prior verification when the review diff is unchanged.
|
|
704
716
|
|
|
705
|
-
Returns True when
|
|
717
|
+
Returns True when an existing session is reused (no new submission). Covers
|
|
718
|
+
both terminal ``succeeded`` runs and in-flight ``created``/``running`` runs
|
|
719
|
+
so a repeated ``asrt verify`` does not submit twice for the same diff.
|
|
706
720
|
"""
|
|
707
721
|
stored_hash = read_review_diff_hash(assertion_dir)
|
|
708
722
|
if stored_hash != review_diff_hash:
|
|
709
723
|
return False
|
|
710
724
|
|
|
711
725
|
last_status = read_verify_last_status(assertion_dir)
|
|
712
|
-
|
|
726
|
+
reusable_statuses = {
|
|
727
|
+
SessionStatus.SUCCEEDED.value,
|
|
728
|
+
SessionStatus.CREATED.value,
|
|
729
|
+
SessionStatus.RUNNING.value,
|
|
730
|
+
}
|
|
731
|
+
if last_status not in reusable_statuses:
|
|
713
732
|
return False
|
|
714
733
|
|
|
715
734
|
session_id = _read_optional_verify_session_id(assertion_dir)
|
|
@@ -726,12 +745,12 @@ def _try_reuse_successful_verify(
|
|
|
726
745
|
{
|
|
727
746
|
"session_id": session_id,
|
|
728
747
|
"url": session_url,
|
|
729
|
-
"status":
|
|
748
|
+
"status": last_status,
|
|
730
749
|
"reused": True,
|
|
731
750
|
}
|
|
732
751
|
)
|
|
733
752
|
)
|
|
734
|
-
|
|
753
|
+
elif last_status == SessionStatus.SUCCEEDED.value:
|
|
735
754
|
typer.echo(
|
|
736
755
|
"Review diff unchanged since the last successful verification — "
|
|
737
756
|
f"reusing session {session_id}."
|
|
@@ -740,6 +759,13 @@ def _try_reuse_successful_verify(
|
|
|
740
759
|
typer.echo(
|
|
741
760
|
"Embed `asrt get-link` in the PR body if the branch name may differ."
|
|
742
761
|
)
|
|
762
|
+
else:
|
|
763
|
+
typer.echo(
|
|
764
|
+
"Review diff unchanged since the last verification submission — "
|
|
765
|
+
f"session {session_id} is still {last_status}."
|
|
766
|
+
)
|
|
767
|
+
typer.echo(f"URL: {session_url}")
|
|
768
|
+
typer.echo("Poll `asrt verify-status` instead of submitting again.")
|
|
743
769
|
return True
|
|
744
770
|
|
|
745
771
|
|
|
@@ -763,24 +789,25 @@ def verify(
|
|
|
763
789
|
this command only submits — poll `asrt verify-status` (one-shot) on whatever
|
|
764
790
|
interval your harness prefers to learn the outcome.
|
|
765
791
|
|
|
766
|
-
When the review-scope diff is unchanged since the last
|
|
767
|
-
|
|
768
|
-
Pass ``--force`` to always
|
|
792
|
+
When the review-scope diff is unchanged since the last verification
|
|
793
|
+
submission, the CLI reuses that session instead of submitting again —
|
|
794
|
+
including while a prior run is still ``running``. Pass ``--force`` to always
|
|
795
|
+
submit.
|
|
769
796
|
"""
|
|
770
797
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
771
798
|
stack_id = metadata.stack_id
|
|
772
799
|
assert stack_id is not None
|
|
773
|
-
|
|
774
|
-
ctx, metadata
|
|
775
|
-
)
|
|
800
|
+
_, review_diff_hash = _compute_review_diff_fingerprint(ctx)
|
|
776
801
|
|
|
777
|
-
if not force and
|
|
802
|
+
if not force and _try_reuse_verify(
|
|
778
803
|
ctx.assertion_dir,
|
|
779
804
|
review_diff_hash=review_diff_hash,
|
|
780
805
|
json_output=json_output,
|
|
781
806
|
):
|
|
782
807
|
return
|
|
783
808
|
|
|
809
|
+
bundle_bytes, _, review_diff_hash = _build_verify_bundle(ctx, metadata)
|
|
810
|
+
|
|
784
811
|
client = AssertionClient()
|
|
785
812
|
resp = client.verify(
|
|
786
813
|
stack_id=stack_id,
|
|
@@ -21,7 +21,7 @@ Before any `asrt` call:
|
|
|
21
21
|
|
|
22
22
|
- You are inside a git repository.
|
|
23
23
|
- `asrt install` has been run once in this repo. It installs the skill files you are reading right now (plus the `AGENTS.md` / `CLAUDE.md` pointer and `.gitignore` rules). This is an onboarding step, not a per-run one.
|
|
24
|
-
- `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by repo
|
|
24
|
+
- `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by GitHub `owner/repo`) — not in a repo `.env`. If an `asrt` call fails with "No saved Assertion token … run `asrt login`", the user needs to run `asrt login` (or export `ASSERTION_API_TOKEN`); surface that to them rather than retrying.
|
|
25
25
|
- `asrt session start` has been run for this work session. It resets `.assertion/` (except `.assertion/seed.py`) and records the verification base — the merge-base with the upstream tracking branch by default, or an explicit `--from <ref>`. If `asrt checkpoint` or `asrt verify` errors with "No verification base recorded", run `asrt session start` and retry.
|
|
26
26
|
|
|
27
27
|
**First command sequence for a new work session:**
|
|
@@ -144,7 +144,7 @@ Do not call `verify` mid-task. It is not a substitute for checkpoints; it is the
|
|
|
144
144
|
|
|
145
145
|
`verify` is **non-blocking**: it submits the verification and returns immediately with the session id and URL. The verification itself can take several minutes — you poll `asrt verify-status` on your own cadence to learn the outcome. This avoids tripping any shell-timeout in the harness.
|
|
146
146
|
|
|
147
|
-
When the review diff is unchanged since the last
|
|
147
|
+
When the review diff is unchanged since the last verification submission, `asrt verify` reuses that session instead of submitting again — including while a prior run is still `running` (poll `asrt verify-status` instead). After a **succeeded** run, reuse skips a redundant submission. Use `asrt verify --force` to submit anyway. After a new git branch, run `asrt session start` only when the verification base should change — check the scope line it prints, or pass `--from <ref>` if the default is wrong.
|
|
148
148
|
|
|
149
149
|
```
|
|
150
150
|
asrt verify # submit; prints session id + URL, exits 0
|
|
@@ -43,7 +43,9 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
43
43
|
|
|
44
44
|
@respx.mock
|
|
45
45
|
def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
|
|
46
|
-
route = respx.get(STACKS_URL).mock(
|
|
46
|
+
route = respx.get(STACKS_URL).mock(
|
|
47
|
+
return_value=_stacks_response("acme/mvp", "acme/docs")
|
|
48
|
+
)
|
|
47
49
|
|
|
48
50
|
result = runner.invoke(main.app, ["login", "--token", "alk_new"])
|
|
49
51
|
|
|
@@ -51,6 +53,8 @@ def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
|
|
|
51
53
|
assert "connected to stack" in result.output.lower()
|
|
52
54
|
assert route.called
|
|
53
55
|
assert credentials.resolve_token(repo) == "alk_new"
|
|
56
|
+
assert credentials.resolve_token(None, "acme/mvp") == "alk_new"
|
|
57
|
+
assert credentials.resolve_token(None, "acme/docs") == "alk_new"
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
@respx.mock
|