albus-cli 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {albus_cli-0.7.0 → albus_cli-0.8.0}/PKG-INFO +2 -2
  2. {albus_cli-0.7.0 → albus_cli-0.8.0}/pyproject.toml +2 -2
  3. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/client.py +67 -30
  4. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/auth.py +4 -2
  5. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/organization.py +32 -1
  6. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/credentials.py +122 -7
  7. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/main.py +5 -1
  8. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_auth.py +8 -9
  9. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_billing.py +1 -2
  10. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_cli.py +26 -1
  11. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_credentials.py +118 -1
  12. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_health.py +0 -1
  13. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_invites.py +2 -3
  14. albus_cli-0.8.0/tests/test_organization.py +165 -0
  15. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_tokens.py +1 -2
  16. {albus_cli-0.7.0 → albus_cli-0.8.0}/uv.lock +5 -5
  17. albus_cli-0.7.0/tests/test_organization.py +0 -59
  18. {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  19. {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/pull_request_template.md +0 -0
  21. {albus_cli-0.7.0 → albus_cli-0.8.0}/.github/workflows/close-pull-requests.yml +0 -0
  22. {albus_cli-0.7.0 → albus_cli-0.8.0}/.gitignore +0 -0
  23. {albus_cli-0.7.0 → albus_cli-0.8.0}/AGENTS.md +0 -0
  24. {albus_cli-0.7.0 → albus_cli-0.8.0}/CONTRIBUTING.md +0 -0
  25. {albus_cli-0.7.0 → albus_cli-0.8.0}/LICENSE +0 -0
  26. {albus_cli-0.7.0 → albus_cli-0.8.0}/Makefile +0 -0
  27. {albus_cli-0.7.0 → albus_cli-0.8.0}/README.md +0 -0
  28. {albus_cli-0.7.0 → albus_cli-0.8.0}/RELEASING.md +0 -0
  29. {albus_cli-0.7.0 → albus_cli-0.8.0}/SECURITY.md +0 -0
  30. {albus_cli-0.7.0 → albus_cli-0.8.0}/install.ps1 +0 -0
  31. {albus_cli-0.7.0 → albus_cli-0.8.0}/install.sh +0 -0
  32. {albus_cli-0.7.0 → albus_cli-0.8.0}/pyrightconfig.json +0 -0
  33. {albus_cli-0.7.0 → albus_cli-0.8.0}/setup.sh +0 -0
  34. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/__init__.py +0 -0
  35. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/__main__.py +0 -0
  36. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/__init__.py +0 -0
  37. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/agents.py +0 -0
  38. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/billing.py +0 -0
  39. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/invites.py +0 -0
  40. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/memories.py +0 -0
  41. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/models.py +0 -0
  42. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/secrets.py +0 -0
  43. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/sessions.py +0 -0
  44. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/status.py +0 -0
  45. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/tokens.py +0 -0
  46. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/commands/traces.py +0 -0
  47. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/context.py +0 -0
  48. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/docs.py +0 -0
  49. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/oauth.py +0 -0
  50. {albus_cli-0.7.0 → albus_cli-0.8.0}/src/albus_cli/output.py +0 -0
  51. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/__init__.py +0 -0
  52. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/conftest.py +0 -0
  53. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_bump_sdk.py +0 -0
  54. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_memories.py +0 -0
  55. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_models.py +0 -0
  56. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_oauth.py +0 -0
  57. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_release_scripts.py +0 -0
  58. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_status.py +0 -0
  59. {albus_cli-0.7.0 → albus_cli-0.8.0}/tests/test_traces.py +0 -0
  60. {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/bump-sdk +0 -0
  61. {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/check +0 -0
  62. {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/mirror-release +0 -0
  63. {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/publish +0 -0
  64. {albus_cli-0.7.0 → albus_cli-0.8.0}/tools/release_state.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: albus-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Command-line client for the Albus REST API
5
5
  Project-URL: Homepage, https://github.com/albusgroup/albus-cli
6
6
  Project-URL: Repository, https://github.com/albusgroup/albus-cli
@@ -9,7 +9,7 @@ Author: Albus
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.11
12
- Requires-Dist: albus-sdk==0.14.0
12
+ Requires-Dist: albus-sdk==0.16.0
13
13
  Requires-Dist: typer<0.28,>=0.26.8
14
14
  Description-Content-Type: text/markdown
15
15
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "albus-cli"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Command-line client for the Albus REST API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -13,7 +13,7 @@ authors = [{ name = "Albus" }]
13
13
  dependencies = [
14
14
  # Pinned, not floated: the published CLI states the one SDK version it was
15
15
  # tested against, and bumping it is a reviewed edit made when releasing.
16
- "albus-sdk==0.14.0",
16
+ "albus-sdk==0.16.0",
17
17
  "typer>=0.26.8,<0.28",
18
18
  ]
19
19
 
@@ -45,6 +45,17 @@ class _Used:
45
45
  _used: _Used | None = None
46
46
 
47
47
 
48
+ @dataclass(frozen=True)
49
+ class Selection:
50
+ """A client on the browser session and the session it was read
51
+ from, for `organization use` and `default`: what they save
52
+ afterwards must be keyed to the sign-in they validated, not
53
+ whichever one is stored by then."""
54
+
55
+ api: Albus
56
+ session: credentials.Session
57
+
58
+
48
59
  def base_url(configured: str | None) -> str:
49
60
  """The API a command talks to, spelled as the credential store keys
50
61
  it. The SDK picks its first server when none is configured."""
@@ -67,15 +78,13 @@ def client(
67
78
  if key:
68
79
  # The environment wins over a stored session deliberately, so CI
69
80
  # and agent harnesses inject a credential without touching disk.
70
- return _client(base_url, timeout, organization, api_key=key)
81
+ return _client(base_url, timeout, organization, key, api_key=True)
71
82
 
72
- stored = credentials.load(base_url)
83
+ stored = credentials.session(base_url)
73
84
  if stored is None:
74
85
  raise _signed_out(base_url)
75
86
 
76
- return bearer_client(
77
- base_url, timeout, organization, _current(base_url, stored)
78
- )
87
+ return _session_client(base_url, timeout, organization, stored)
79
88
 
80
89
 
81
90
  def signed_in_client(
@@ -85,16 +94,21 @@ def signed_in_client(
85
94
  only a human bearer token for use it directly: an API key is a 401
86
95
  there, and the environment winning would be a 401 nobody asked
87
96
  for."""
88
- stored = credentials.load(base_url)
89
- if stored is None:
90
- raise NotSignedIn(
91
- f"not signed in to {base_url}. This command needs a browser "
92
- f"session, which {API_KEY_ENV} cannot stand in for: run "
93
- "`albus login`."
94
- )
97
+ stored = _signed_in(base_url)
98
+ return _session_client(base_url, timeout, organization, stored)
95
99
 
96
- return bearer_client(
97
- base_url, timeout, organization, _current(base_url, stored)
100
+
101
+ def selection(
102
+ base_url: str, timeout: float | None, organization: str | None
103
+ ) -> Selection:
104
+ """A client on the browser session acting in exactly `organization`
105
+ — none meaning the server's default, not the saved one. The
106
+ organization commands read `/whoami` through it before they write,
107
+ so a request that fails leaves the selection as it was."""
108
+ stored = _signed_in(base_url)
109
+ current = _current(base_url, stored.credential)
110
+ return Selection(
111
+ bearer_client(base_url, timeout, organization, current), stored
98
112
  )
99
113
 
100
114
 
@@ -120,7 +134,7 @@ def bearer_client(
120
134
  """A client on one credential, for `login` reading back the session
121
135
  it just stored rather than whatever precedence would pick."""
122
136
  return _client(
123
- base_url, timeout, organization, access_token=credential.access_token
137
+ base_url, timeout, organization, credential.access_token, api_key=False
124
138
  )
125
139
 
126
140
 
@@ -197,6 +211,34 @@ def shadows_logout() -> str | None:
197
211
  return _shadowing("commands stay authenticated until it is unset.")
198
212
 
199
213
 
214
+ def _signed_in(base_url: str) -> credentials.Session:
215
+ stored = credentials.session(base_url)
216
+ if stored is None:
217
+ raise NotSignedIn(
218
+ f"not signed in to {base_url}. This command needs a browser "
219
+ f"session, which {API_KEY_ENV} cannot stand in for: run "
220
+ "`albus login`."
221
+ )
222
+
223
+ return stored
224
+
225
+
226
+ def _session_client(
227
+ base_url: str,
228
+ timeout: float | None,
229
+ organization: str | None,
230
+ stored: credentials.Session,
231
+ ) -> Albus:
232
+ """A client on a stored session, acting in the organization named on
233
+ the command line, else the one saved with the session."""
234
+ return bearer_client(
235
+ base_url,
236
+ timeout,
237
+ organization or stored.organization,
238
+ _current(base_url, stored.credential),
239
+ )
240
+
241
+
200
242
  def _signed_out(base_url: str) -> NotSignedIn:
201
243
  """Nothing stored to authenticate with, whether this command found
202
244
  no entry or a concurrent `logout` removed it."""
@@ -226,33 +268,28 @@ def _client(
226
268
  base_url: str,
227
269
  timeout: float | None,
228
270
  organization: str | None,
271
+ credential: str,
229
272
  *,
230
- api_key: str | None = None,
231
- access_token: str | None = None,
273
+ api_key: bool,
232
274
  ) -> Albus:
275
+ """A client sending `credential`, which is an API key or a session's
276
+ access token: both are a bearer token on the wire, and the SDK takes
277
+ either as its one `api_key`. Which it was is what `rejected` needs
278
+ to know."""
233
279
  global _used
234
- _used = _Used(base_url=base_url, api_key=api_key is not None)
235
- return _built(
236
- base_url,
237
- timeout,
238
- organization,
239
- api_key=api_key,
240
- access_token=access_token,
241
- )
280
+ _used = _Used(base_url=base_url, api_key=api_key)
281
+ return _built(base_url, timeout, organization, credential)
242
282
 
243
283
 
244
284
  def _built(
245
285
  base_url: str,
246
286
  timeout: float | None,
247
287
  organization: str | None,
248
- *,
249
- api_key: str | None = None,
250
- access_token: str | None = None,
288
+ credential: str | None = None,
251
289
  ) -> Albus:
252
290
  headers = {ORGANIZATION_HEADER: organization} if organization else {}
253
291
  return Albus(
254
- api_key=api_key,
255
- access_token=access_token,
292
+ api_key=credential,
256
293
  server_url=base_url,
257
294
  client=httpx.Client(
258
295
  follow_redirects=True, timeout=timeout, headers=headers
@@ -45,8 +45,8 @@ def login(ctx: typer.Context, no_browser: NoBrowser = False) -> None:
45
45
  )
46
46
  # A browser session authenticates a user, so `/whoami` names one;
47
47
  # an account it did not name is not one to claim under "Signed in".
48
- user = signed_in.auth.whoami().user
49
- output.done(_signed_in(user))
48
+ caller = signed_in.auth.whoami()
49
+ output.done(_signed_in(caller.user))
50
50
  output.field("API", api)
51
51
  output.field("Credential", output.abbreviated(credentials.path()))
52
52
  if session.refresh_token is None:
@@ -55,6 +55,8 @@ def login(ctx: typer.Context, no_browser: NoBrowser = False) -> None:
55
55
  "access token expires: enable Allow Offline Access on the API."
56
56
  )
57
57
 
58
+ output.emit(caller)
59
+
58
60
  _also(client.shadows_session())
59
61
  _next_steps()
60
62
 
@@ -5,7 +5,8 @@ from typing import Annotated, Literal
5
5
 
6
6
  import typer
7
7
 
8
- from albus_cli.context import sdk
8
+ from albus_cli import client, credentials
9
+ from albus_cli.context import base_url, sdk, timeout
9
10
  from albus_cli.output import emit
10
11
 
11
12
  app = typer.Typer(
@@ -25,6 +26,36 @@ def get(ctx: typer.Context) -> None:
25
26
  emit(sdk(ctx).organization.get_organization())
26
27
 
27
28
 
29
+ @app.command("use")
30
+ def use(
31
+ ctx: typer.Context,
32
+ organization_id: Annotated[
33
+ str, typer.Argument(metavar="ORGANIZATION_ID", help="Organization ID.")
34
+ ],
35
+ ) -> None:
36
+ """Persist the organization the browser session acts in."""
37
+ if not organization_id:
38
+ raise typer.BadParameter("ORGANIZATION_ID must not be empty.")
39
+
40
+ _select(ctx, organization_id)
41
+
42
+
43
+ @app.command("default")
44
+ def default(ctx: typer.Context) -> None:
45
+ """Use the earliest-joined organization by default."""
46
+ _select(ctx, None)
47
+
48
+
49
+ def _select(ctx: typer.Context, organization_id: str | None) -> None:
50
+ """Prove the membership with `/whoami`, then save it against the
51
+ session that proved it."""
52
+ api = base_url(ctx)
53
+ selected = client.selection(api, timeout(ctx), organization_id)
54
+ caller = selected.api.auth.whoami()
55
+ credentials.set_organization(api, organization_id, selected.session)
56
+ emit(caller)
57
+
58
+
28
59
  @app.command("rename")
29
60
  def rename(
30
61
  ctx: typer.Context,
@@ -9,6 +9,7 @@ and a half-written file would look like a broken login.
9
9
  import fcntl
10
10
  import json
11
11
  import os
12
+ import secrets
12
13
  import tempfile
13
14
  from collections.abc import Callable, Iterator
14
15
  from contextlib import contextmanager
@@ -22,6 +23,10 @@ FILE_NAME = "credentials.json"
22
23
  VERSION = 1
23
24
  FILE_MODE = 0o600
24
25
  DIRECTORY_MODE = 0o700
26
+ ORGANIZATION_ID = "organization_id"
27
+ # Names one sign-in. Renewal rotates the tokens but keeps this, so it
28
+ # is what tells a refreshed session from a replaced one.
29
+ SESSION_ID = "session_id"
25
30
 
26
31
 
27
32
  class CorruptFile(Exception):
@@ -31,6 +36,17 @@ class CorruptFile(Exception):
31
36
  super().__init__(f"{file} is not a credentials file: {reason}")
32
37
 
33
38
 
39
+ class SessionReplaced(Exception):
40
+ """The stored session changed under a command that had validated it:
41
+ a concurrent `login` or `logout` for the same API."""
42
+
43
+ def __init__(self, base_url: str) -> None:
44
+ super().__init__(
45
+ f"the session for {base_url} changed while this command ran. "
46
+ "Run it again."
47
+ )
48
+
49
+
34
50
  @dataclass(frozen=True)
35
51
  class Credential:
36
52
  access_token: str
@@ -40,36 +56,102 @@ class Credential:
40
56
  expires_at: float
41
57
 
42
58
 
59
+ @dataclass(frozen=True)
60
+ class Session:
61
+ """A stored browser session: the credential and the organization it
62
+ acts in, read from one version of the entry. Read separately, a
63
+ concurrent sign-in could pair one account's token with another's
64
+ organization."""
65
+
66
+ credential: Credential
67
+ organization: str | None
68
+ # None for an entry written before sign-ins were named.
69
+ identity: str | None
70
+
71
+
43
72
  def path() -> Path:
44
73
  """The credentials file the CLI reads first and always writes."""
45
74
  return _directory() / FILE_NAME
46
75
 
47
76
 
48
77
  def load(base_url: str) -> Credential | None:
49
- """The stored credential for base_url, or None if there is none.
78
+ """The stored credential for base_url, or None if there is none."""
79
+ stored = session(base_url)
80
+ return stored.credential if stored else None
81
+
82
+
83
+ def session(base_url: str) -> Session | None:
84
+ """The stored session for base_url, or None if there is none.
50
85
 
51
86
  A file or entry this version cannot read counts as "none": the
52
87
  caller then tells the user to sign in, which is the fix either way.
53
88
  Readers take no lock because every write lands by rename.
54
89
  """
55
- primary = path()
56
- file = primary if primary.exists() else _legacy_path()
57
90
  try:
58
- entries = _entries(file)
91
+ entry = _stored_entries().get(_key(base_url))
59
92
  except (OSError, CorruptFile):
60
93
  return None
61
94
 
62
- return _credential(entries.get(_key(base_url)))
95
+ credential = _credential(entry)
96
+ if credential is None:
97
+ return None
98
+
99
+ return Session(credential, _selected_organization(entry), _identity(entry))
63
100
 
64
101
 
65
102
  def save(base_url: str, credential: Credential) -> None:
66
- _replace_entry(base_url, asdict(credential))
103
+ """Store a fresh sign-in, under a name no earlier sign-in had."""
104
+ _replace_entry(
105
+ base_url, {**asdict(credential), SESSION_ID: secrets.token_hex(16)}
106
+ )
67
107
 
68
108
 
69
109
  def delete(base_url: str) -> None:
70
110
  _replace_entry(base_url, None)
71
111
 
72
112
 
113
+ def organization(base_url: str) -> str | None:
114
+ """The browser session's saved organization, if it still has one."""
115
+ stored = session(base_url)
116
+ return stored.organization if stored else None
117
+
118
+
119
+ def set_organization(
120
+ base_url: str, organization_id: str | None, validated: Session
121
+ ) -> None:
122
+ """Remember or clear the selected organization for one browser session.
123
+
124
+ `validated` is the session the caller proved the membership with; the
125
+ write goes through only while the entry is still that sign-in, so
126
+ one that landed in between does not inherit another account's
127
+ organization. A renewal in between is the same sign-in and does not
128
+ stand in the way. A session still living in the legacy file is carried
129
+ into the primary one with its neighbours, since that is the only
130
+ file this version writes.
131
+ """
132
+ target = path()
133
+ directory = target.parent
134
+ directory.mkdir(parents=True, exist_ok=True, mode=DIRECTORY_MODE)
135
+ directory.chmod(DIRECTORY_MODE)
136
+
137
+ with _lock(directory):
138
+ entries = _stored_entries()
139
+ fields = _mapping(entries.get(_key(base_url)))
140
+ if fields is None or _credential(fields) is None:
141
+ raise SessionReplaced(base_url)
142
+
143
+ if _identity(fields) != validated.identity:
144
+ raise SessionReplaced(base_url)
145
+
146
+ if organization_id is None:
147
+ fields.pop(ORGANIZATION_ID, None)
148
+ else:
149
+ fields[ORGANIZATION_ID] = organization_id
150
+
151
+ entries[_key(base_url)] = fields
152
+ _write(target, {"version": VERSION, "credentials": entries})
153
+
154
+
73
155
  def renew(
74
156
  base_url: str, mint: Callable[[Credential], Credential]
75
157
  ) -> Credential | None:
@@ -102,7 +184,16 @@ def renew(
102
184
  if minted == current:
103
185
  return current
104
186
 
105
- entries[_key(base_url)] = asdict(minted)
187
+ entry = asdict(minted)
188
+ stored = entries.get(_key(base_url))
189
+ for name, kept in (
190
+ (ORGANIZATION_ID, _selected_organization(stored)),
191
+ (SESSION_ID, _identity(stored)),
192
+ ):
193
+ if kept is not None:
194
+ entry[name] = kept
195
+
196
+ entries[_key(base_url)] = entry
106
197
  _write(target, {"version": VERSION, "credentials": entries})
107
198
  return minted
108
199
 
@@ -124,6 +215,13 @@ def _legacy_path() -> Path:
124
215
  return Path.home() / ".albus" / FILE_NAME
125
216
 
126
217
 
218
+ def _stored_entries() -> dict[str, object]:
219
+ """The entries of the file a reader sees: the primary one, or the
220
+ legacy one while the primary has never been written."""
221
+ primary = path()
222
+ return _entries(primary if primary.exists() else _legacy_path())
223
+
224
+
127
225
  def _key(base_url: str) -> str:
128
226
  return base_url.rstrip("/")
129
227
 
@@ -238,6 +336,23 @@ def _credential(entry: object) -> Credential | None:
238
336
  return Credential(access_token, refresh_token, float(expires_at))
239
337
 
240
338
 
339
+ def _selected_organization(entry: object) -> str | None:
340
+ return _text(entry, ORGANIZATION_ID)
341
+
342
+
343
+ def _identity(entry: object) -> str | None:
344
+ return _text(entry, SESSION_ID)
345
+
346
+
347
+ def _text(entry: object, name: str) -> str | None:
348
+ fields = _mapping(entry)
349
+ if fields is None:
350
+ return None
351
+
352
+ value = fields.get(name)
353
+ return value if isinstance(value, str) else None
354
+
355
+
241
356
  def _mapping(value: object) -> dict[str, object] | None:
242
357
  if not isinstance(value, dict):
243
358
  return None
@@ -119,7 +119,11 @@ def health(ctx: typer.Context) -> None:
119
119
  def main() -> None:
120
120
  try:
121
121
  app()
122
- except (NotSignedIn, oauth.LoginError) as unauthenticated:
122
+ except (
123
+ NotSignedIn,
124
+ oauth.LoginError,
125
+ credentials.SessionReplaced,
126
+ ) as unauthenticated:
123
127
  fail(str(unauthenticated))
124
128
  except credentials.CorruptFile as corrupt:
125
129
  fail(f"{corrupt}. Delete or repair it, then run `albus login`.")
@@ -75,7 +75,6 @@ def test_api_key_wins_over_a_stored_session(
75
75
 
76
76
  assert result.exit_code == 0, result.output
77
77
  assert albus.init_kwargs[0]["api_key"] == "env-key"
78
- assert albus.init_kwargs[0]["access_token"] is None
79
78
 
80
79
 
81
80
  def test_stored_session_authenticates_with_a_bearer_token(
@@ -86,8 +85,7 @@ def test_stored_session_authenticates_with_a_bearer_token(
86
85
  result = runner.invoke(app, ["sessions", "list"])
87
86
 
88
87
  assert result.exit_code == 0, result.output
89
- assert signed_out.init_kwargs[0]["access_token"] == "stored-access"
90
- assert signed_out.init_kwargs[0]["api_key"] is None
88
+ assert signed_out.init_kwargs[0]["api_key"] == "stored-access"
91
89
 
92
90
 
93
91
  def test_expired_session_is_refreshed_and_stored(
@@ -111,7 +109,7 @@ def test_expired_session_is_refreshed_and_stored(
111
109
 
112
110
  assert result.exit_code == 0, result.output
113
111
  assert grants == ["refresh"]
114
- assert signed_out.init_kwargs[0]["access_token"] == "renewed-access"
112
+ assert signed_out.init_kwargs[0]["api_key"] == "renewed-access"
115
113
  saved = credentials.load(BASE_URL)
116
114
  assert saved is not None
117
115
  assert saved.access_token == "renewed-access"
@@ -157,7 +155,7 @@ def test_a_session_expiring_within_the_leeway_is_refreshed(
157
155
  )
158
156
 
159
157
  assert runner.invoke(app, ["sessions", "list"]).exit_code == 0
160
- assert signed_out.init_kwargs[0]["access_token"] == "renewed-access"
158
+ assert signed_out.init_kwargs[0]["api_key"] == "renewed-access"
161
159
 
162
160
 
163
161
  def test_rejected_refresh_reports_an_expired_session(
@@ -206,7 +204,7 @@ def test_no_credential_names_both_ways_to_get_one(
206
204
  assert "ALBUS_API_KEY" in reported
207
205
 
208
206
 
209
- def test_login_stores_the_session_and_names_the_account(
207
+ def test_login_stores_the_session_and_prints_whoami(
210
208
  signed_out: FakeAlbus, monkeypatch: pytest.MonkeyPatch, config_dir: Path
211
209
  ) -> None:
212
210
  announced: list[str] = []
@@ -230,6 +228,8 @@ def test_login_stores_the_session_and_names_the_account(
230
228
  assert announced == ["cli"]
231
229
  assert "https://albus.us.auth0.com/authorize" in result.output
232
230
  assert "Signed in as carlo@albus.sh in Albus" in result.output
231
+ assert '"active_organization"' in result.output
232
+ assert '"organizations"' in result.output
233
233
  assert "https://albus.sh/api" in result.output
234
234
  assert str(config_dir / "credentials.json") in result.output
235
235
  saved = credentials.load(BASE_URL)
@@ -239,7 +239,7 @@ def test_login_stores_the_session_and_names_the_account(
239
239
  # The account is resolved over the API with the new credential,
240
240
  # never by reading the token.
241
241
  assert signed_out.calls[-1].name == "whoami"
242
- assert signed_out.init_kwargs[-1]["access_token"] == "fresh-access"
242
+ assert signed_out.init_kwargs[-1]["api_key"] == "fresh-access"
243
243
 
244
244
 
245
245
  def signs_in(
@@ -270,7 +270,7 @@ def test_login_names_the_account_it_just_signed_in_as(
270
270
 
271
271
  assert result.exit_code == 0, result.output
272
272
  assert "as carlo@albus.sh" in result.output
273
- assert albus.init_kwargs[-1]["access_token"] == "fresh-access"
273
+ assert albus.init_kwargs[-1]["api_key"] == "fresh-access"
274
274
 
275
275
 
276
276
  def test_login_says_an_api_key_shadows_the_new_session(
@@ -441,7 +441,6 @@ def test_whoami_names_the_caller_the_next_command_sends(
441
441
 
442
442
  assert runner.invoke(app, ["whoami"]).exit_code == 0
443
443
  assert albus.init_kwargs[0]["api_key"] == "env-key"
444
- assert albus.init_kwargs[0]["access_token"] is None
445
444
 
446
445
 
447
446
  def test_whoami_works_with_only_an_api_key(
@@ -72,8 +72,7 @@ def test_checkout_uses_the_session_over_an_api_key(
72
72
  "success_url": "https://example.com/ok",
73
73
  "cancel_url": "https://example.com/back",
74
74
  }
75
- assert albus.init_kwargs[0]["access_token"] == "stored-access"
76
- assert albus.init_kwargs[0]["api_key"] is None
75
+ assert albus.init_kwargs[0]["api_key"] == "stored-access"
77
76
  assert json.loads(result.stdout)["url"].startswith("https://")
78
77
 
79
78
 
@@ -6,7 +6,8 @@ import pytest
6
6
  from albus_sdk import errors, models
7
7
  from typer.testing import CliRunner
8
8
 
9
- from albus_cli import client
9
+ from albus_cli import client, credentials
10
+ from albus_cli.credentials import Credential
10
11
  from albus_cli.main import app, main
11
12
  from tests.conftest import FakeAlbus
12
13
 
@@ -234,6 +235,30 @@ def test_without_an_org_the_server_picks_the_organization(
234
235
  assert sent_organization(albus) is None
235
236
 
236
237
 
238
+ def test_saved_org_selects_the_organization_for_a_browser_session(
239
+ albus: FakeAlbus, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
240
+ ) -> None:
241
+ monkeypatch.setenv(credentials.CONFIG_DIR_ENV, str(tmp_path / "config"))
242
+ monkeypatch.delenv(credentials.XDG_CONFIG_HOME_ENV, raising=False)
243
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
244
+ monkeypatch.delenv(client.API_KEY_ENV, raising=False)
245
+ credentials.save(
246
+ "https://api.albus.sh",
247
+ Credential("access", "refresh", 1_800_000_000),
248
+ )
249
+ stored = credentials.session("https://api.albus.sh")
250
+ assert stored is not None
251
+ credentials.set_organization("https://api.albus.sh", "o2", stored)
252
+
253
+ result = runner.invoke(
254
+ app,
255
+ ["--base-url", "https://api.albus.sh", "sessions", "list"],
256
+ )
257
+
258
+ assert result.exit_code == 0, result.output
259
+ assert sent_organization(albus) == "o2"
260
+
261
+
237
262
  def test_secret_value_read_from_stdin(albus: FakeAlbus) -> None:
238
263
  result = runner.invoke(
239
264
  app, ["secrets", "create", "gemini-key"], input="s3cret\n"
@@ -31,6 +31,13 @@ def credential(access_token: str = "access") -> Credential:
31
31
  )
32
32
 
33
33
 
34
+ def stored() -> credentials.Session:
35
+ """The session as a command reads it before acting on it."""
36
+ session = credentials.session(BASE_URL)
37
+ assert session is not None
38
+ return session
39
+
40
+
34
41
  def test_saved_credential_round_trips() -> None:
35
42
  credentials.save(BASE_URL, credential())
36
43
 
@@ -101,7 +108,9 @@ def test_a_trailing_slash_is_the_same_entry() -> None:
101
108
  credentials.save(f"{BASE_URL}/", credential())
102
109
 
103
110
  assert credentials.load(BASE_URL) == credential()
104
- assert entries() == {BASE_URL: entry("access")}
111
+ assert entries() == {
112
+ BASE_URL: {**entry("access"), "session_id": stored().identity}
113
+ }
105
114
 
106
115
 
107
116
  def test_two_base_urls_do_not_collide() -> None:
@@ -126,6 +135,114 @@ def test_delete_keeps_the_other_entries() -> None:
126
135
  assert credentials.load(other) is not None
127
136
 
128
137
 
138
+ def test_saved_organization_round_trips_with_a_credential() -> None:
139
+ credentials.save(BASE_URL, credential())
140
+
141
+ credentials.set_organization(BASE_URL, "42", stored())
142
+
143
+ assert credentials.organization(BASE_URL) == "42"
144
+ assert credentials.load(BASE_URL) == credential()
145
+
146
+
147
+ def test_saving_a_fresh_login_clears_the_saved_organization() -> None:
148
+ credentials.save(BASE_URL, credential())
149
+ credentials.set_organization(BASE_URL, "42", stored())
150
+
151
+ credentials.save(BASE_URL, credential("new-login"))
152
+
153
+ assert credentials.organization(BASE_URL) is None
154
+
155
+
156
+ def test_session_reads_credential_and_organization_together() -> None:
157
+ credentials.save(BASE_URL, credential())
158
+ credentials.set_organization(BASE_URL, "42", stored())
159
+
160
+ assert credentials.session(BASE_URL) == credentials.Session(
161
+ credential(), "42", stored().identity
162
+ )
163
+
164
+
165
+ def test_legacy_session_can_select_an_organization(tmp_path: Path) -> None:
166
+ legacy = tmp_path / "home/.albus/credentials.json"
167
+ other = "https://other.example/api"
168
+ write_document(
169
+ legacy,
170
+ {
171
+ "version": 1,
172
+ "credentials": {BASE_URL: entry("legacy"), other: entry("kept")},
173
+ },
174
+ )
175
+
176
+ credentials.set_organization(BASE_URL, "42", stored())
177
+
178
+ assert credentials.organization(BASE_URL) == "42"
179
+ assert credentials.load(BASE_URL) == credential("legacy")
180
+ assert credentials.load(other) == credential("kept")
181
+ assert json.loads(legacy.read_text())["credentials"][BASE_URL] == entry(
182
+ "legacy"
183
+ )
184
+
185
+
186
+ def test_set_organization_refuses_a_session_it_did_not_validate() -> None:
187
+ """The entry a concurrent `login` wrote is another account's; a
188
+ `logout` leaves none. Neither takes the organization."""
189
+ credentials.save(BASE_URL, credential())
190
+ validated = stored()
191
+
192
+ credentials.save(BASE_URL, credential())
193
+
194
+ with pytest.raises(credentials.SessionReplaced):
195
+ credentials.set_organization(BASE_URL, "42", validated)
196
+
197
+ credentials.delete(BASE_URL)
198
+
199
+ with pytest.raises(credentials.SessionReplaced):
200
+ credentials.set_organization(BASE_URL, "42", validated)
201
+
202
+ assert credentials.organization(BASE_URL) is None
203
+
204
+
205
+ def test_set_organization_accepts_a_session_renewed_meanwhile() -> None:
206
+ """Renewal rotates every token but is the same sign-in, and must
207
+ not read as a replacement."""
208
+ credentials.save(BASE_URL, credential())
209
+ validated = stored()
210
+
211
+ credentials.renew(BASE_URL, lambda current: credential("renewed"))
212
+ credentials.set_organization(BASE_URL, "42", validated)
213
+
214
+ assert credentials.session(BASE_URL) == credentials.Session(
215
+ credential("renewed"), "42", validated.identity
216
+ )
217
+
218
+
219
+ def test_login_over_an_unnamed_session_is_a_replacement(
220
+ tmp_path: Path,
221
+ ) -> None:
222
+ """An entry from before sign-ins were named has no identity; the
223
+ login that replaces it has one, so it is told apart all the same."""
224
+ write_document(
225
+ tmp_path / "home/.albus/credentials.json",
226
+ {"version": 1, "credentials": {BASE_URL: entry("legacy")}},
227
+ )
228
+ validated = stored()
229
+ assert validated.identity is None
230
+
231
+ credentials.save(BASE_URL, credential())
232
+
233
+ with pytest.raises(credentials.SessionReplaced):
234
+ credentials.set_organization(BASE_URL, "42", validated)
235
+
236
+
237
+ def test_refresh_keeps_the_saved_organization() -> None:
238
+ credentials.save(BASE_URL, credential())
239
+ credentials.set_organization(BASE_URL, "42", stored())
240
+
241
+ credentials.renew(BASE_URL, lambda current: credential("renewed"))
242
+
243
+ assert credentials.organization(BASE_URL) == "42"
244
+
245
+
129
246
  def test_delete_without_a_file_writes_nothing() -> None:
130
247
  """Signing out must not be what creates the credentials file."""
131
248
  credentials.delete(BASE_URL)
@@ -25,7 +25,6 @@ def test_health_needs_no_credential(
25
25
 
26
26
  assert result.exit_code == 0, result.output
27
27
  assert json.loads(result.stdout) == {"status": "ok"}
28
- assert albus.init_kwargs[0]["access_token"] is None
29
28
  assert albus.init_kwargs[0]["api_key"] is None
30
29
 
31
30
 
@@ -76,8 +76,7 @@ def test_the_session_is_used_over_an_api_key(
76
76
  result = runner.invoke(app, ["invites", "create", "new@example.com"])
77
77
 
78
78
  assert result.exit_code == 0, result.output
79
- assert signed_in.init_kwargs[0]["access_token"] == "stored-access"
80
- assert signed_in.init_kwargs[0]["api_key"] is None
79
+ assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
81
80
 
82
81
 
83
82
  def test_list(signed_in: FakeAlbus) -> None:
@@ -86,7 +85,7 @@ def test_list(signed_in: FakeAlbus) -> None:
86
85
  assert result.exit_code == 0, result.output
87
86
  assert signed_in.calls[0].name == "list_invites"
88
87
  assert json.loads(result.stdout)["invites"][0]["id"] == "i1"
89
- assert signed_in.init_kwargs[0]["api_key"] is None
88
+ assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
90
89
 
91
90
 
92
91
  def test_revoke_prints_nothing(signed_in: FakeAlbus) -> None:
@@ -0,0 +1,165 @@
1
+ """`albus organization` maps one command to each `/organization` operation
2
+ and accepts either credential, since the API does."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from typer.testing import CliRunner
9
+
10
+ from albus_cli import client, credentials
11
+ from albus_cli.credentials import Credential
12
+ from albus_cli.main import app
13
+ from tests.conftest import FakeAlbus
14
+ from tests.test_cli import sent_organization
15
+
16
+ runner = CliRunner()
17
+
18
+ BASE_URL = "https://albus.sh/api"
19
+ SESSION = Credential("access", "refresh", 1_800_000_000)
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
24
+ """Point the store at tmp_path so the real ~/.config is untouched."""
25
+ directory = tmp_path / "config"
26
+ monkeypatch.setenv(credentials.CONFIG_DIR_ENV, str(directory))
27
+ monkeypatch.delenv(credentials.XDG_CONFIG_HOME_ENV, raising=False)
28
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
29
+ return directory
30
+
31
+
32
+ @pytest.fixture
33
+ def signed_in(albus: FakeAlbus, monkeypatch: pytest.MonkeyPatch) -> FakeAlbus:
34
+ """The SDK fake behind a stored browser session, with no API key:
35
+ `use` and `default` act on the session alone."""
36
+ monkeypatch.delenv(client.API_KEY_ENV, raising=False)
37
+ credentials.save(BASE_URL, SESSION)
38
+ return albus
39
+
40
+
41
+ def stored() -> credentials.Session:
42
+ session = credentials.session(BASE_URL)
43
+ assert session is not None
44
+ return session
45
+
46
+
47
+ def test_get(albus: FakeAlbus) -> None:
48
+ result = runner.invoke(app, ["organization", "get"])
49
+
50
+ assert result.exit_code == 0, result.output
51
+ assert albus.calls[0].name == "get_organization"
52
+
53
+
54
+ def test_use_persists_a_membership(signed_in: FakeAlbus) -> None:
55
+ result = runner.invoke(app, ["organization", "use", "o2"])
56
+
57
+ assert result.exit_code == 0, result.output
58
+ assert signed_in.calls[0].name == "whoami"
59
+ assert sent_organization(signed_in) == "o2"
60
+ assert credentials.organization(BASE_URL) == "o2"
61
+
62
+
63
+ def test_use_needs_a_browser_session(albus: FakeAlbus) -> None:
64
+ result = runner.invoke(app, ["organization", "use", "o2"])
65
+
66
+ assert result.exit_code != 0
67
+ assert albus.calls == []
68
+ assert credentials.organization(BASE_URL) is None
69
+
70
+
71
+ def test_use_rejects_an_empty_id(signed_in: FakeAlbus) -> None:
72
+ credentials.set_organization(BASE_URL, "o2", stored())
73
+
74
+ result = runner.invoke(app, ["organization", "use", ""])
75
+
76
+ assert result.exit_code != 0
77
+ assert signed_in.calls == []
78
+ assert credentials.organization(BASE_URL) == "o2"
79
+
80
+
81
+ def test_use_refuses_to_save_onto_a_session_that_replaced_the_one_it_checked(
82
+ signed_in: FakeAlbus, monkeypatch: pytest.MonkeyPatch
83
+ ) -> None:
84
+ """A `login` landing between `/whoami` and the write is another
85
+ account, whose session must not inherit this one's organization."""
86
+ whoami = signed_in.auth.whoami
87
+
88
+ def logged_in_meanwhile(**kwargs: object) -> object:
89
+ credentials.save(BASE_URL, Credential("other", None, 1_800_000_000))
90
+ return whoami(**kwargs)
91
+
92
+ monkeypatch.setattr(signed_in.auth, "whoami", logged_in_meanwhile)
93
+
94
+ result = runner.invoke(app, ["organization", "use", "o2"])
95
+
96
+ assert isinstance(result.exception, credentials.SessionReplaced)
97
+ assert credentials.organization(BASE_URL) is None
98
+
99
+
100
+ def test_default_clears_the_saved_membership(signed_in: FakeAlbus) -> None:
101
+ credentials.set_organization(BASE_URL, "o2", stored())
102
+
103
+ result = runner.invoke(app, ["organization", "default"])
104
+
105
+ assert result.exit_code == 0, result.output
106
+ assert sent_organization(signed_in) is None
107
+ assert credentials.organization(BASE_URL) is None
108
+ active = json.loads(result.stdout)["user"]["active_organization"]
109
+ assert active["name"] == "Albus"
110
+
111
+
112
+ def test_default_keeps_the_saved_membership_when_the_request_fails(
113
+ signed_in: FakeAlbus, monkeypatch: pytest.MonkeyPatch
114
+ ) -> None:
115
+ credentials.set_organization(BASE_URL, "o2", stored())
116
+
117
+ def refused(**kwargs: object) -> None:
118
+ raise ConnectionError("refused")
119
+
120
+ monkeypatch.setattr(signed_in.auth, "whoami", refused)
121
+
122
+ result = runner.invoke(app, ["organization", "default"])
123
+
124
+ assert result.exit_code != 0
125
+ assert credentials.organization(BASE_URL) == "o2"
126
+
127
+
128
+ def test_rename(albus: FakeAlbus) -> None:
129
+ result = runner.invoke(app, ["organization", "rename", "Albus Labs"])
130
+
131
+ assert result.exit_code == 0, result.output
132
+ assert albus.calls[0].name == "update_organization"
133
+ assert albus.calls[0].kwargs == {"name": "Albus Labs"}
134
+
135
+
136
+ def test_members(albus: FakeAlbus) -> None:
137
+ result = runner.invoke(app, ["organization", "members"])
138
+
139
+ assert result.exit_code == 0, result.output
140
+ assert albus.calls[0].name == "list_organization_members"
141
+ assert json.loads(result.stdout)["members"][0]["user_id"] == "u1"
142
+
143
+
144
+ def test_set_role(albus: FakeAlbus) -> None:
145
+ result = runner.invoke(app, ["organization", "set-role", "u1", "member"])
146
+
147
+ assert result.exit_code == 0, result.output
148
+ assert albus.calls[0].name == "set_organization_member_role"
149
+ assert albus.calls[0].kwargs == {"user_id": "u1", "role": "member"}
150
+
151
+
152
+ def test_set_role_rejects_an_unknown_role(albus: FakeAlbus) -> None:
153
+ result = runner.invoke(app, ["organization", "set-role", "u1", "owner"])
154
+
155
+ assert result.exit_code != 0
156
+ assert albus.calls == []
157
+
158
+
159
+ def test_remove_member_prints_nothing(albus: FakeAlbus) -> None:
160
+ result = runner.invoke(app, ["organization", "remove-member", "u1"])
161
+
162
+ assert result.exit_code == 0, result.output
163
+ assert albus.calls[0].name == "remove_organization_member"
164
+ assert albus.calls[0].kwargs == {"user_id": "u1"}
165
+ assert result.stdout == ""
@@ -85,8 +85,7 @@ def test_the_session_is_used_over_an_api_key(
85
85
  monkeypatch.setenv(client.API_KEY_ENV, "env-key")
86
86
 
87
87
  assert runner.invoke(app, ["tokens", "list"]).exit_code == 0
88
- assert signed_in.init_kwargs[0]["access_token"] == "stored-access"
89
- assert signed_in.init_kwargs[0]["api_key"] is None
88
+ assert signed_in.init_kwargs[0]["api_key"] == "stored-access"
90
89
 
91
90
 
92
91
  def test_only_an_api_key_asks_for_a_sign_in(
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "albus-cli"
11
- version = "0.7.0"
11
+ version = "0.8.0"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "albus-sdk" },
@@ -24,7 +24,7 @@ dev = [
24
24
 
25
25
  [package.metadata]
26
26
  requires-dist = [
27
- { name = "albus-sdk", specifier = "==0.14.0" },
27
+ { name = "albus-sdk", specifier = "==0.16.0" },
28
28
  { name = "typer", specifier = ">=0.26.8,<0.28" },
29
29
  ]
30
30
 
@@ -37,16 +37,16 @@ dev = [
37
37
 
38
38
  [[package]]
39
39
  name = "albus-sdk"
40
- version = "0.14.0"
40
+ version = "0.16.0"
41
41
  source = { registry = "https://pypi.org/simple" }
42
42
  dependencies = [
43
43
  { name = "httpcore" },
44
44
  { name = "httpx" },
45
45
  { name = "pydantic" },
46
46
  ]
47
- sdist = { url = "https://files.pythonhosted.org/packages/e3/ed/6833e825d1eba3cdd241f105532d3c935d26a1395922a8836fc8d18eeb9f/albus_sdk-0.14.0.tar.gz", hash = "sha256:192eeed8c07a2485f71a95e6572e80b18c200764316cc012cae1cfee08552699", size = 107954, upload-time = "2026-09-06T02:39:59.857Z" }
47
+ sdist = { url = "https://files.pythonhosted.org/packages/99/b1/17e68fe0ee1af1205b3133b41d68b40c60d1895fe561de4085617fd6ad56/albus_sdk-0.16.0.tar.gz", hash = "sha256:61fd36f55cbd0fa59f6dd038caeb1fe7364aa0e590c7341517ae116595b5b548", size = 89838, upload-time = "2026-09-08T02:46:29.364Z" }
48
48
  wheels = [
49
- { url = "https://files.pythonhosted.org/packages/6d/1a/59dfd146603452b1dd0f3db5e1a85e3cba6c72a08981f062d300fad32926/albus_sdk-0.14.0-py3-none-any.whl", hash = "sha256:419fa1147acef93e48fbcdeeed8f2355ac807a44638bb1679400f992d8a9ae7c", size = 173652, upload-time = "2026-09-06T02:39:58.475Z" },
49
+ { url = "https://files.pythonhosted.org/packages/26/0e/22e4d6437a71c3faa7caeca4bb5d7af5a491b043c46e715e30aab905ca6a/albus_sdk-0.16.0-py3-none-any.whl", hash = "sha256:8409e81c1745043ca41fc5783ec89f8c206987bb9f0e62f7bfacae7e4e8d3633", size = 146438, upload-time = "2026-09-08T02:46:28.184Z" },
50
50
  ]
51
51
 
52
52
  [[package]]
@@ -1,59 +0,0 @@
1
- """`albus organization` maps one command to each `/organization` operation
2
- and accepts either credential, since the API does."""
3
-
4
- import json
5
-
6
- from typer.testing import CliRunner
7
-
8
- from albus_cli.main import app
9
- from tests.conftest import FakeAlbus
10
-
11
- runner = CliRunner()
12
-
13
-
14
- def test_get(albus: FakeAlbus) -> None:
15
- result = runner.invoke(app, ["organization", "get"])
16
-
17
- assert result.exit_code == 0, result.output
18
- assert albus.calls[0].name == "get_organization"
19
- assert json.loads(result.stdout)["name"] == "Albus"
20
-
21
-
22
- def test_rename(albus: FakeAlbus) -> None:
23
- result = runner.invoke(app, ["organization", "rename", "Albus Labs"])
24
-
25
- assert result.exit_code == 0, result.output
26
- assert albus.calls[0].name == "update_organization"
27
- assert albus.calls[0].kwargs == {"name": "Albus Labs"}
28
-
29
-
30
- def test_members(albus: FakeAlbus) -> None:
31
- result = runner.invoke(app, ["organization", "members"])
32
-
33
- assert result.exit_code == 0, result.output
34
- assert albus.calls[0].name == "list_organization_members"
35
- assert json.loads(result.stdout)["members"][0]["user_id"] == "u1"
36
-
37
-
38
- def test_set_role(albus: FakeAlbus) -> None:
39
- result = runner.invoke(app, ["organization", "set-role", "u1", "member"])
40
-
41
- assert result.exit_code == 0, result.output
42
- assert albus.calls[0].name == "set_organization_member_role"
43
- assert albus.calls[0].kwargs == {"user_id": "u1", "role": "member"}
44
-
45
-
46
- def test_set_role_rejects_an_unknown_role(albus: FakeAlbus) -> None:
47
- result = runner.invoke(app, ["organization", "set-role", "u1", "owner"])
48
-
49
- assert result.exit_code != 0
50
- assert albus.calls == []
51
-
52
-
53
- def test_remove_member_prints_nothing(albus: FakeAlbus) -> None:
54
- result = runner.invoke(app, ["organization", "remove-member", "u1"])
55
-
56
- assert result.exit_code == 0, result.output
57
- assert albus.calls[0].name == "remove_organization_member"
58
- assert albus.calls[0].kwargs == {"user_id": "u1"}
59
- assert result.stdout == ""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes