assertion-cli 0.5.17__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.
Files changed (37) hide show
  1. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/PKG-INFO +14 -9
  2. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/README.md +13 -8
  3. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/api.py +10 -6
  4. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/PKG-INFO +14 -9
  5. assertion_cli-0.5.18/credentials.py +253 -0
  6. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/main.py +10 -5
  7. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/SKILL.md +1 -1
  9. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_auth.py +5 -1
  10. assertion_cli-0.5.18/tests/test_credentials.py +189 -0
  11. assertion_cli-0.5.17/credentials.py +0 -120
  12. assertion_cli-0.5.17/tests/test_credentials.py +0 -122
  13. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/SOURCES.txt +0 -0
  14. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/dependency_links.txt +0 -0
  15. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/entry_points.txt +0 -0
  16. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/requires.txt +0 -0
  17. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/top_level.txt +0 -0
  18. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/bundle.py +0 -0
  19. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/git.py +0 -0
  20. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/link.py +0 -0
  21. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/models.py +0 -0
  22. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/session.py +0 -0
  23. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/setup.cfg +0 -0
  24. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/AGENTS.md +0 -0
  25. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/__init__.py +0 -0
  26. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_api.py +0 -0
  27. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_bundle.py +0 -0
  28. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_decision.py +0 -0
  29. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_git.py +0 -0
  30. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_install.py +0 -0
  31. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_link.py +0 -0
  32. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_main.py +0 -0
  33. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_prompt.py +0 -0
  34. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_session.py +0 -0
  35. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_session_start.py +0 -0
  36. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_verify.py +0 -0
  37. {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_verify_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.17
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`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, 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
+ 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 entry for the current repo path in `~/.assertion/credentials.json`;
73
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
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`) — keyed by the repo's path:
44
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
45
45
 
46
46
  ```json
47
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, 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
+ 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 entry for the current repo path in `~/.assertion/credentials.json`;
60
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
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
- 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.
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
- token = resolve_token(find_git_root_or_none(Path.cwd()))
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 (if you moved or renamed the folder, log in again).\n"
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.17
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`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, 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
+ 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 entry for the current repo path in `~/.assertion/credentials.json`;
73
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
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 the repo it should be used for. The token is stored keyed
129
- by the repo's path, so each repo can target a different workspace.
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(token, repo_root=repo_root)
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
- if remove_token(repo_root=repo_root):
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
- source = describe_source(repo_root)
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."
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.17"
7
+ version = "0.5.18"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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 path) — 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.
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:**
@@ -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(return_value=_stacks_response("acme/mvp"))
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
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import stat
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import credentials
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
14
+ """Point the store at a throwaway dir and clear the env-var backup."""
15
+ home = tmp_path / "home"
16
+ monkeypatch.setattr(credentials, "assertion_home", lambda: home)
17
+ monkeypatch.delenv(credentials.API_TOKEN_ENV, raising=False)
18
+ return home
19
+
20
+
21
+ @pytest.fixture
22
+ def repo(tmp_path: Path) -> Path:
23
+ root = tmp_path / "repo"
24
+ root.mkdir()
25
+ return root
26
+
27
+
28
+ def test_save_and_resolve_round_trip(repo: Path) -> None:
29
+ credentials.save_token(
30
+ "alk_one", repo_root=repo, repo_full_names=["acme/mvp", "Acme/Docs"]
31
+ )
32
+ assert credentials.resolve_token(repo, "ACME/MVP") == "alk_one"
33
+
34
+
35
+ def test_resolve_unknown_repo_returns_none(repo: Path, tmp_path: Path) -> None:
36
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
37
+ other = tmp_path / "other"
38
+ other.mkdir()
39
+ assert credentials.resolve_token(other) is None
40
+
41
+
42
+ def test_resolve_none_repo_root() -> None:
43
+ assert credentials.resolve_token(None) is None
44
+
45
+
46
+ def test_save_writes_file_mode_0600(repo: Path) -> None:
47
+ path = credentials.save_token("alk_one", repo_root=repo)
48
+ mode = stat.S_IMODE(path.stat().st_mode)
49
+ assert mode == 0o600
50
+
51
+
52
+ def test_store_shape(repo: Path) -> None:
53
+ path = credentials.save_token(
54
+ "alk_one", repo_root=repo, repo_full_names=["Acme/MVP", "acme/mvp"]
55
+ )
56
+ data = json.loads(path.read_text())
57
+ assert data["version"] == credentials.CREDENTIALS_VERSION
58
+ assert data["tokens"]["alk_one"] == ["acme/mvp"]
59
+ assert data["repo_index"]["acme/mvp"] == "alk_one"
60
+ assert data["repos"][str(repo.resolve())] == "alk_one"
61
+
62
+
63
+ def test_save_is_idempotent_overwrite(repo: Path) -> None:
64
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
65
+ credentials.save_token("alk_two", repo_root=repo, repo_full_names=["acme/mvp"])
66
+ assert credentials.resolve_token(repo, "acme/mvp") == "alk_two"
67
+ store = credentials.load_store()
68
+ assert len(store["repos"]) == 1
69
+ assert len(store["tokens"]) == 1
70
+ assert store["repo_index"] == {"acme/mvp": "alk_two"}
71
+
72
+
73
+ def test_distinct_repos_get_distinct_entries(tmp_path: Path) -> None:
74
+ a, b = tmp_path / "a", tmp_path / "b"
75
+ a.mkdir()
76
+ b.mkdir()
77
+ credentials.save_token("alk_a", repo_root=a, repo_full_names=["acme/a"])
78
+ credentials.save_token("alk_b", repo_root=b, repo_full_names=["acme/b"])
79
+ assert credentials.resolve_token(a, "acme/a") == "alk_a"
80
+ assert credentials.resolve_token(b, "acme/b") == "alk_b"
81
+
82
+
83
+ def test_load_store_missing_file_is_empty() -> None:
84
+ assert credentials.load_store() == {
85
+ "version": credentials.CREDENTIALS_VERSION,
86
+ "tokens": {},
87
+ "repo_index": {},
88
+ "repos": {},
89
+ }
90
+
91
+
92
+ def test_load_store_tolerates_corrupt_file(_isolate_home: Path) -> None:
93
+ path = credentials.credentials_path()
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ path.write_text("{ not json", encoding="utf-8")
96
+ assert credentials.load_store() == {
97
+ "version": credentials.CREDENTIALS_VERSION,
98
+ "tokens": {},
99
+ "repo_index": {},
100
+ "repos": {},
101
+ }
102
+
103
+
104
+ def test_remove_token(repo: Path) -> None:
105
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
106
+ assert credentials.remove_token(repo_root=repo, repo_full_name="acme/mvp") is True
107
+ assert credentials.resolve_token(repo, "acme/mvp") is None
108
+ # Removing again is a no-op.
109
+ assert credentials.remove_token(repo_root=repo) is False
110
+
111
+
112
+ def test_describe_source_repo_entry(repo: Path) -> None:
113
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
114
+ assert credentials.describe_source(repo, "acme/mvp") == (
115
+ "the saved token for acme/mvp"
116
+ )
117
+
118
+
119
+ def test_describe_source_env_var(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
120
+ monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
121
+ # No stored entry for this repo -> falls through to the env-var source.
122
+ assert credentials.describe_source(repo) == (
123
+ f"the {credentials.API_TOKEN_ENV} environment variable"
124
+ )
125
+
126
+
127
+ def test_describe_source_repo_entry_wins_over_env(
128
+ repo: Path, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
131
+ credentials.save_token("alk_one", repo_root=repo)
132
+ assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
133
+
134
+
135
+ def test_describe_source_none(repo: Path) -> None:
136
+ assert credentials.describe_source(repo) is None
137
+
138
+
139
+ def test_resolve_legacy_path_store(repo: Path) -> None:
140
+ path = credentials.credentials_path()
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ path.write_text(
143
+ json.dumps({"version": 1, "repos": {str(repo.resolve()): "alk_old"}}),
144
+ encoding="utf-8",
145
+ )
146
+
147
+ assert credentials.resolve_token(repo, "acme/mvp") == "alk_old"
148
+
149
+
150
+ def test_load_store_builds_repo_index_from_token_only_v2_store() -> None:
151
+ path = credentials.credentials_path()
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_text(
154
+ json.dumps({"version": 2, "tokens": {"alk_one": ["Acme/MVP"]}}),
155
+ encoding="utf-8",
156
+ )
157
+
158
+ assert credentials.load_store()["repo_index"] == {"acme/mvp": "alk_one"}
159
+ assert credentials.resolve_token(None, "acme/mvp") == "alk_one"
160
+
161
+
162
+ def test_resolve_repo_name_after_reclone(repo: Path, tmp_path: Path) -> None:
163
+ moved = tmp_path / "moved"
164
+ moved.mkdir()
165
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
166
+
167
+ assert credentials.resolve_token(moved, "acme/mvp") == "alk_one"
168
+
169
+
170
+ def test_remove_token_keeps_other_repo_names(repo: Path) -> None:
171
+ credentials.save_token(
172
+ "alk_one", repo_root=repo, repo_full_names=["acme/mvp", "acme/docs"]
173
+ )
174
+
175
+ assert credentials.remove_token(repo_root=repo, repo_full_name="acme/mvp") is True
176
+ assert credentials.resolve_token(None, "acme/mvp") is None
177
+ assert credentials.resolve_token(None, "acme/docs") == "alk_one"
178
+
179
+
180
+ def test_resaving_same_token_removes_stale_repo_index_entries(repo: Path) -> None:
181
+ credentials.save_token(
182
+ "alk_one", repo_root=repo, repo_full_names=["acme/mvp", "acme/docs"]
183
+ )
184
+ credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
185
+
186
+ store = credentials.load_store()
187
+ assert store["tokens"]["alk_one"] == ["acme/mvp"]
188
+ assert store["repo_index"] == {"acme/mvp": "alk_one"}
189
+ assert credentials.resolve_token(None, "acme/docs") is None
@@ -1,120 +0,0 @@
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
@@ -1,122 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import stat
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- import credentials
10
-
11
-
12
- @pytest.fixture(autouse=True)
13
- def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
14
- """Point the store at a throwaway dir and clear the env-var backup."""
15
- home = tmp_path / "home"
16
- monkeypatch.setattr(credentials, "assertion_home", lambda: home)
17
- monkeypatch.delenv(credentials.API_TOKEN_ENV, raising=False)
18
- return home
19
-
20
-
21
- @pytest.fixture
22
- def repo(tmp_path: Path) -> Path:
23
- root = tmp_path / "repo"
24
- root.mkdir()
25
- return root
26
-
27
-
28
- def test_save_and_resolve_round_trip(repo: Path) -> None:
29
- credentials.save_token("alk_one", repo_root=repo)
30
- assert credentials.resolve_token(repo) == "alk_one"
31
-
32
-
33
- def test_resolve_unknown_repo_returns_none(repo: Path, tmp_path: Path) -> None:
34
- credentials.save_token("alk_one", repo_root=repo)
35
- other = tmp_path / "other"
36
- other.mkdir()
37
- assert credentials.resolve_token(other) is None
38
-
39
-
40
- def test_resolve_none_repo_root() -> None:
41
- assert credentials.resolve_token(None) is None
42
-
43
-
44
- def test_save_writes_file_mode_0600(repo: Path) -> None:
45
- path = credentials.save_token("alk_one", repo_root=repo)
46
- mode = stat.S_IMODE(path.stat().st_mode)
47
- assert mode == 0o600
48
-
49
-
50
- def test_store_shape(repo: Path) -> None:
51
- path = credentials.save_token("alk_one", repo_root=repo)
52
- data = json.loads(path.read_text())
53
- assert data["version"] == credentials.CREDENTIALS_VERSION
54
- assert data["repos"][str(repo.resolve())] == "alk_one"
55
-
56
-
57
- def test_save_is_idempotent_overwrite(repo: Path) -> None:
58
- credentials.save_token("alk_one", repo_root=repo)
59
- credentials.save_token("alk_two", repo_root=repo)
60
- assert credentials.resolve_token(repo) == "alk_two"
61
- store = credentials.load_store()
62
- assert len(store["repos"]) == 1
63
-
64
-
65
- def test_distinct_repos_get_distinct_entries(tmp_path: Path) -> None:
66
- a, b = tmp_path / "a", tmp_path / "b"
67
- a.mkdir()
68
- b.mkdir()
69
- credentials.save_token("alk_a", repo_root=a)
70
- credentials.save_token("alk_b", repo_root=b)
71
- assert credentials.resolve_token(a) == "alk_a"
72
- assert credentials.resolve_token(b) == "alk_b"
73
-
74
-
75
- def test_load_store_missing_file_is_empty() -> None:
76
- assert credentials.load_store() == {
77
- "version": credentials.CREDENTIALS_VERSION,
78
- "repos": {},
79
- }
80
-
81
-
82
- def test_load_store_tolerates_corrupt_file(_isolate_home: Path) -> None:
83
- path = credentials.credentials_path()
84
- path.parent.mkdir(parents=True, exist_ok=True)
85
- path.write_text("{ not json", encoding="utf-8")
86
- assert credentials.load_store() == {
87
- "version": credentials.CREDENTIALS_VERSION,
88
- "repos": {},
89
- }
90
-
91
-
92
- def test_remove_token(repo: Path) -> None:
93
- credentials.save_token("alk_one", repo_root=repo)
94
- assert credentials.remove_token(repo_root=repo) is True
95
- assert credentials.resolve_token(repo) is None
96
- # Removing again is a no-op.
97
- assert credentials.remove_token(repo_root=repo) is False
98
-
99
-
100
- def test_describe_source_repo_entry(repo: Path) -> None:
101
- credentials.save_token("alk_one", repo_root=repo)
102
- assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
103
-
104
-
105
- def test_describe_source_env_var(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
106
- monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
107
- # No stored entry for this repo -> falls through to the env-var source.
108
- assert credentials.describe_source(repo) == (
109
- f"the {credentials.API_TOKEN_ENV} environment variable"
110
- )
111
-
112
-
113
- def test_describe_source_repo_entry_wins_over_env(
114
- repo: Path, monkeypatch: pytest.MonkeyPatch
115
- ) -> None:
116
- monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
117
- credentials.save_token("alk_one", repo_root=repo)
118
- assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
119
-
120
-
121
- def test_describe_source_none(repo: Path) -> None:
122
- assert credentials.describe_source(repo) is None
File without changes
File without changes
File without changes
File without changes
File without changes