albus-cli 0.4.1__tar.gz → 0.7.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 (66) hide show
  1. {albus_cli-0.4.1 → albus_cli-0.7.0}/AGENTS.md +13 -4
  2. {albus_cli-0.4.1 → albus_cli-0.7.0}/PKG-INFO +49 -10
  3. {albus_cli-0.4.1 → albus_cli-0.7.0}/README.md +47 -8
  4. {albus_cli-0.4.1 → albus_cli-0.7.0}/RELEASING.md +44 -8
  5. {albus_cli-0.4.1 → albus_cli-0.7.0}/pyproject.toml +2 -2
  6. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/client.py +35 -10
  7. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/auth.py +18 -3
  8. albus_cli-0.7.0/src/albus_cli/commands/billing.py +67 -0
  9. albus_cli-0.7.0/src/albus_cli/commands/invites.py +54 -0
  10. albus_cli-0.7.0/src/albus_cli/commands/memories.py +56 -0
  11. albus_cli-0.7.0/src/albus_cli/commands/organization.py +64 -0
  12. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/sessions.py +42 -14
  13. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/status.py +2 -2
  14. albus_cli-0.7.0/src/albus_cli/commands/traces.py +131 -0
  15. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/context.py +9 -2
  16. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/main.py +35 -12
  17. albus_cli-0.7.0/tests/conftest.py +366 -0
  18. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_auth.py +5 -8
  19. albus_cli-0.7.0/tests/test_billing.py +84 -0
  20. albus_cli-0.7.0/tests/test_bump_sdk.py +95 -0
  21. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_cli.py +58 -9
  22. albus_cli-0.7.0/tests/test_invites.py +98 -0
  23. albus_cli-0.7.0/tests/test_memories.py +74 -0
  24. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_models.py +1 -1
  25. albus_cli-0.7.0/tests/test_organization.py +59 -0
  26. albus_cli-0.7.0/tests/test_release_scripts.py +166 -0
  27. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_status.py +3 -1
  28. albus_cli-0.7.0/tests/test_traces.py +128 -0
  29. albus_cli-0.7.0/tools/bump-sdk +88 -0
  30. {albus_cli-0.4.1 → albus_cli-0.7.0}/tools/publish +32 -12
  31. {albus_cli-0.4.1 → albus_cli-0.7.0}/tools/release_state.sh +37 -0
  32. {albus_cli-0.4.1 → albus_cli-0.7.0}/uv.lock +5 -5
  33. albus_cli-0.4.1/src/albus_cli/commands/invites.py +0 -33
  34. albus_cli-0.4.1/tests/conftest.py +0 -194
  35. albus_cli-0.4.1/tests/test_invites.py +0 -62
  36. {albus_cli-0.4.1 → albus_cli-0.7.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  37. {albus_cli-0.4.1 → albus_cli-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  38. {albus_cli-0.4.1 → albus_cli-0.7.0}/.github/pull_request_template.md +0 -0
  39. {albus_cli-0.4.1 → albus_cli-0.7.0}/.github/workflows/close-pull-requests.yml +0 -0
  40. {albus_cli-0.4.1 → albus_cli-0.7.0}/.gitignore +0 -0
  41. {albus_cli-0.4.1 → albus_cli-0.7.0}/CONTRIBUTING.md +0 -0
  42. {albus_cli-0.4.1 → albus_cli-0.7.0}/LICENSE +0 -0
  43. {albus_cli-0.4.1 → albus_cli-0.7.0}/Makefile +0 -0
  44. {albus_cli-0.4.1 → albus_cli-0.7.0}/SECURITY.md +0 -0
  45. {albus_cli-0.4.1 → albus_cli-0.7.0}/install.ps1 +0 -0
  46. {albus_cli-0.4.1 → albus_cli-0.7.0}/install.sh +0 -0
  47. {albus_cli-0.4.1 → albus_cli-0.7.0}/pyrightconfig.json +0 -0
  48. {albus_cli-0.4.1 → albus_cli-0.7.0}/setup.sh +0 -0
  49. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/__init__.py +0 -0
  50. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/__main__.py +0 -0
  51. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/__init__.py +0 -0
  52. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/agents.py +0 -0
  53. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/models.py +0 -0
  54. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/secrets.py +0 -0
  55. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/commands/tokens.py +0 -0
  56. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/credentials.py +0 -0
  57. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/docs.py +0 -0
  58. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/oauth.py +0 -0
  59. {albus_cli-0.4.1 → albus_cli-0.7.0}/src/albus_cli/output.py +0 -0
  60. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/__init__.py +0 -0
  61. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_credentials.py +0 -0
  62. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_health.py +0 -0
  63. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_oauth.py +0 -0
  64. {albus_cli-0.4.1 → albus_cli-0.7.0}/tests/test_tokens.py +0 -0
  65. {albus_cli-0.4.1 → albus_cli-0.7.0}/tools/check +0 -0
  66. {albus_cli-0.4.1 → albus_cli-0.7.0}/tools/mirror-release +0 -0
@@ -22,13 +22,20 @@ Run everything from `clients/albus-cli/`. `uv` owns the virtualenv and
22
22
  | `make test` | `pytest` |
23
23
  | `make check` | all of the above |
24
24
  | `./tools/check` | `make check`, and what a release runs |
25
+ | `./tools/bump-sdk <sdk-version> <cli-version>` | Pin a published SDK version and relock, uncommitted |
25
26
  | `./tools/publish --dry-run <version>` | Validate a release without uploading |
26
27
  | `./tools/publish [--testpypi] <version>` | Upload, after explicit authorization |
28
+ | `./tools/publish --non-interactive <version>` | Upload from GitHub Actions, where the dispatch is the authorization |
27
29
  | `./tools/mirror-release [--dry-run] <version> <checkout>` | Copy the released tree to the public mirror |
28
30
 
29
- See [`RELEASING.md`](RELEASING.md). Publishing is never implied by a merge: a
30
- version reaches PyPI only when it was explicitly authorized, and published
31
- versions are immutable.
31
+ See [`RELEASING.md`](RELEASING.md) and
32
+ [`client-release-automation.md`](../../.agents/plans/client-release-automation.md).
33
+ Publishing is never implied by a merge: a version reaches PyPI only when it was
34
+ explicitly authorized, and published versions are immutable. The release
35
+ scripts also run in GitHub Actions, where `--non-interactive` and the
36
+ `GITHUB_REF` release-state check apply, both keyed on `GITHUB_ACTIONS`. That
37
+ keeps a local release on the prompt; it is not a boundary, since the token is
38
+ what authorizes an upload.
32
39
 
33
40
  ## Conventions
34
41
 
@@ -72,7 +79,9 @@ versions are immutable.
72
79
  - The `albus-sdk` dependency is pinned to one version. The published CLI has
73
80
  to name an SDK version that exists on PyPI, so the pin is bumped
74
81
  deliberately in the pull request that needs the newer SDK — never floated to
75
- whatever is latest at install time.
82
+ whatever is latest at install time. `tools/bump-sdk` makes that edit — the
83
+ pin, `uv.lock`, and the CLI's own version — and deliberately does not commit
84
+ it: a release opens it as a pull request for review.
76
85
  - The public repository accepts issues, not pull requests. A user-reported bug
77
86
  is fixed here and reaches them in the next release, which closes the issue
78
87
  via a `Mirror-Issue: albusgroup/albus-cli#<n>` trailer on the fix's commit.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: albus-cli
3
- Version: 0.4.1
3
+ Version: 0.7.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.10.0
12
+ Requires-Dist: albus-sdk==0.14.0
13
13
  Requires-Dist: typer<0.28,>=0.26.8
14
14
  Description-Content-Type: text/markdown
15
15
 
@@ -78,7 +78,11 @@ export ALBUS_BASE_URL=http://localhost:8080/api # optional; defaults to prod
78
78
  ```
79
79
 
80
80
  `--base-url` overrides `ALBUS_BASE_URL`, and `--timeout` bounds each request
81
- (a waiting `sessions run` long-polls and is exempt).
81
+ (a waiting `sessions run` long-polls and is exempt). A user who belongs to
82
+ several organizations acts in the one joined first; `--org <organization id>`
83
+ (or `ALBUS_ORG`) selects another, and `albus whoami` lists them under
84
+ `organizations` with the selected one as `active_organization`. An API key is
85
+ bound to its organization and ignores `--org`.
82
86
 
83
87
  `albus login` opens a browser on this machine and listens on
84
88
  `127.0.0.1:8484-8487`; on a headless host, forward those ports or export
@@ -115,8 +119,12 @@ albus sessions run my-session -p "and the follow-up?" \
115
119
  albus sessions list
116
120
  albus sessions get my-session --limit 20
117
121
  albus sessions audit my-session --after "$cursor"
122
+ albus sessions cancel my-session
118
123
  albus sessions delete my-session
119
124
 
125
+ albus traces list --agent-name support-triage --status FAILED
126
+ albus traces get "$invocation_key" --no-payloads --attempts all
127
+
120
128
  albus secrets list
121
129
  albus secrets create gemini-key --value ... # or pipe the value on stdin
122
130
  albus secrets get gemini-key
@@ -132,11 +140,32 @@ albus tokens create ci
132
140
  albus tokens get "$id"
133
141
  albus tokens delete "$id"
134
142
 
143
+ albus invites list
135
144
  albus invites create teammate@example.com
145
+ albus invites revoke "$id"
146
+
147
+ albus organization get
148
+ albus organization rename "Albus Labs"
149
+ albus organization members
150
+ albus organization set-role "$user_id" admin
151
+ albus organization remove-member "$user_id"
152
+
153
+ albus billing balance
154
+ albus billing ledger --after "$cursor"
155
+ albus billing checkout 20 --success-url https://... --cancel-url https://...
136
156
 
137
157
  albus models list
158
+
159
+ albus memories groups
160
+ albus memories list --group team-a
161
+ albus memories delete "$id" --group team-a
162
+ albus memories delete-group --group team-a
138
163
  ```
139
164
 
165
+ `memories groups` lists the organization's memory groups with their active
166
+ counts; `memories list --group` reads one group's memories, newest first. Both
167
+ page with `--after` and `--limit`.
168
+
140
169
  `models list` gives the model names `sessions run --model` takes, each with
141
170
  the provider it runs on — which is the provider the `--credential` secret has
142
171
  to be for.
@@ -144,9 +173,19 @@ to be for.
144
173
  `tokens create` prints the only copy of the key value the API ever returns;
145
174
  `list` and `get` return metadata alone.
146
175
 
147
- `invites create` invites a person by email, and they get their own
148
- organization on first sign-in. `CreateInviteRequest`'s `role` and
149
- `organization_id` have no flags: one user is one organization for the beta.
176
+ `invites create` invites a person by email to join your active organization,
177
+ as a `member` unless `--role admin` says otherwise. `invites list` and
178
+ `invites revoke`, like the `organization` member commands, need the admin
179
+ role.
180
+
181
+ `billing checkout` starts a credit purchase and prints the payment page URL
182
+ to send the buyer to; like `tokens` and `invites`, it needs `albus login`
183
+ rather than an API key. `billing balance` and `billing ledger` take either.
184
+
185
+ `traces list` searches the last 31 days of invocations unless `--since` and
186
+ `--until` say otherwise; `traces get` reads one with a page of its spans, and
187
+ `--no-payloads` returns the shape without what each span was given and
188
+ produced.
150
189
 
151
190
  `sessions run` waits for the assistant response by default; pass `--no-wait`
152
191
  to return as soon as the invocation is accepted, or `--wait-timeout` to bound
@@ -154,10 +193,10 @@ the wait server-side. The response carries `message` once the invocation has
154
193
  answered, and omits it otherwise — with `--no-wait`, or when the wait ended
155
194
  first; `albus sessions get` reads the message later.
156
195
 
157
- Pass `--idempotency-key` to make a run retry-safe. The JSON output carries the
158
- effective `idempotency_key`, whether supplied by the caller or generated by
159
- the server — absent only if something between the CLI and Albus strips the
160
- response header on a run that supplied no key.
196
+ Pass `--invocation-key` to name the invocation and make the run retry-safe.
197
+ The JSON output carries the effective `invocation_key`, whether supplied by
198
+ the caller or generated by the server — absent only if something between the
199
+ CLI and Albus strips the response header on a run that supplied no key.
161
200
 
162
201
  `--agent-file` takes a JSON object shaped like the API's `AgentConfig` and
163
202
  covers what the flags do not (MCP servers, provider URL overrides):
@@ -63,7 +63,11 @@ export ALBUS_BASE_URL=http://localhost:8080/api # optional; defaults to prod
63
63
  ```
64
64
 
65
65
  `--base-url` overrides `ALBUS_BASE_URL`, and `--timeout` bounds each request
66
- (a waiting `sessions run` long-polls and is exempt).
66
+ (a waiting `sessions run` long-polls and is exempt). A user who belongs to
67
+ several organizations acts in the one joined first; `--org <organization id>`
68
+ (or `ALBUS_ORG`) selects another, and `albus whoami` lists them under
69
+ `organizations` with the selected one as `active_organization`. An API key is
70
+ bound to its organization and ignores `--org`.
67
71
 
68
72
  `albus login` opens a browser on this machine and listens on
69
73
  `127.0.0.1:8484-8487`; on a headless host, forward those ports or export
@@ -100,8 +104,12 @@ albus sessions run my-session -p "and the follow-up?" \
100
104
  albus sessions list
101
105
  albus sessions get my-session --limit 20
102
106
  albus sessions audit my-session --after "$cursor"
107
+ albus sessions cancel my-session
103
108
  albus sessions delete my-session
104
109
 
110
+ albus traces list --agent-name support-triage --status FAILED
111
+ albus traces get "$invocation_key" --no-payloads --attempts all
112
+
105
113
  albus secrets list
106
114
  albus secrets create gemini-key --value ... # or pipe the value on stdin
107
115
  albus secrets get gemini-key
@@ -117,11 +125,32 @@ albus tokens create ci
117
125
  albus tokens get "$id"
118
126
  albus tokens delete "$id"
119
127
 
128
+ albus invites list
120
129
  albus invites create teammate@example.com
130
+ albus invites revoke "$id"
131
+
132
+ albus organization get
133
+ albus organization rename "Albus Labs"
134
+ albus organization members
135
+ albus organization set-role "$user_id" admin
136
+ albus organization remove-member "$user_id"
137
+
138
+ albus billing balance
139
+ albus billing ledger --after "$cursor"
140
+ albus billing checkout 20 --success-url https://... --cancel-url https://...
121
141
 
122
142
  albus models list
143
+
144
+ albus memories groups
145
+ albus memories list --group team-a
146
+ albus memories delete "$id" --group team-a
147
+ albus memories delete-group --group team-a
123
148
  ```
124
149
 
150
+ `memories groups` lists the organization's memory groups with their active
151
+ counts; `memories list --group` reads one group's memories, newest first. Both
152
+ page with `--after` and `--limit`.
153
+
125
154
  `models list` gives the model names `sessions run --model` takes, each with
126
155
  the provider it runs on — which is the provider the `--credential` secret has
127
156
  to be for.
@@ -129,9 +158,19 @@ to be for.
129
158
  `tokens create` prints the only copy of the key value the API ever returns;
130
159
  `list` and `get` return metadata alone.
131
160
 
132
- `invites create` invites a person by email, and they get their own
133
- organization on first sign-in. `CreateInviteRequest`'s `role` and
134
- `organization_id` have no flags: one user is one organization for the beta.
161
+ `invites create` invites a person by email to join your active organization,
162
+ as a `member` unless `--role admin` says otherwise. `invites list` and
163
+ `invites revoke`, like the `organization` member commands, need the admin
164
+ role.
165
+
166
+ `billing checkout` starts a credit purchase and prints the payment page URL
167
+ to send the buyer to; like `tokens` and `invites`, it needs `albus login`
168
+ rather than an API key. `billing balance` and `billing ledger` take either.
169
+
170
+ `traces list` searches the last 31 days of invocations unless `--since` and
171
+ `--until` say otherwise; `traces get` reads one with a page of its spans, and
172
+ `--no-payloads` returns the shape without what each span was given and
173
+ produced.
135
174
 
136
175
  `sessions run` waits for the assistant response by default; pass `--no-wait`
137
176
  to return as soon as the invocation is accepted, or `--wait-timeout` to bound
@@ -139,10 +178,10 @@ the wait server-side. The response carries `message` once the invocation has
139
178
  answered, and omits it otherwise — with `--no-wait`, or when the wait ended
140
179
  first; `albus sessions get` reads the message later.
141
180
 
142
- Pass `--idempotency-key` to make a run retry-safe. The JSON output carries the
143
- effective `idempotency_key`, whether supplied by the caller or generated by
144
- the server — absent only if something between the CLI and Albus strips the
145
- response header on a run that supplied no key.
181
+ Pass `--invocation-key` to name the invocation and make the run retry-safe.
182
+ The JSON output carries the effective `invocation_key`, whether supplied by
183
+ the caller or generated by the server — absent only if something between the
184
+ CLI and Albus strips the response header on a run that supplied no key.
146
185
 
147
186
  `--agent-file` takes a JSON object shaped like the API's `AgentConfig` and
148
187
  covers what the flags do not (MCP servers, provider URL overrides):
@@ -1,10 +1,12 @@
1
1
  # Releasing the Albus CLI
2
2
 
3
- A release is a maintainer running the scripts below from a clean, up-to-date
4
- `master` checkout of the private Albus repository: they run `./tools/check`,
5
- upload to PyPI or TestPyPI, push the released tree to the public mirror as a
6
- single tagged commit, and close the mirror issues the release fixes. Nothing
7
- publishes from CI, on push, on tag, or on merge.
3
+ A release runs the scripts below from a clean, up-to-date `master` checkout of
4
+ the private Albus repository — by a maintainer locally, or by the dispatched
5
+ release workflow described in
6
+ [Releasing from GitHub Actions](#releasing-from-github-actions). Either way they
7
+ run `./tools/check`, upload to PyPI or TestPyPI, push the released tree to the
8
+ public mirror as a single tagged commit, and close the mirror issues the release
9
+ fixes. Nothing publishes on push, on tag, or on merge.
8
10
 
9
11
  ## One-time PyPI setup
10
12
 
@@ -29,6 +31,18 @@ Bump the `albus-sdk==…` pin in the same pull request when the CLI needs a newe
29
31
  SDK, and release that SDK version first: the pin must name a version that is on
30
32
  PyPI, or the published CLI is uninstallable.
31
33
 
34
+ `tools/bump-sdk` makes that edit:
35
+
36
+ ```bash
37
+ ./tools/bump-sdk 0.11.0 0.2.0
38
+ ```
39
+
40
+ It rewrites the pin and `project.version`, relocks, and checks that `uv.lock`
41
+ resolved the SDK version you named — which is also how an SDK release that did
42
+ not actually reach PyPI is caught. It leaves the edit uncommitted, because the
43
+ edit is a pull request. The release workflow runs the same script and opens
44
+ that pull request itself.
45
+
32
46
  ## Validate
33
47
 
34
48
  From `clients/albus-cli/`, on the branch that carries the version bump:
@@ -68,6 +82,27 @@ uv tool install albus-cli==0.2.0
68
82
  albus --help
69
83
  ```
70
84
 
85
+ ## Releasing from GitHub Actions
86
+
87
+ A release running in GitHub Actions passes `--non-interactive`, which skips the
88
+ confirmation prompt because the dispatch already authorized the upload: a
89
+ maintainer with write access typed the version in and turned `dry_run` off.
90
+ The flag is refused when `GITHUB_ACTIONS` is unset: a phrase a script can type
91
+ is not a confirmation, so a local release keeps the prompt.
92
+ Deciding that from the environment is a guard against skipping the prompt out
93
+ of habit, not a boundary — `UV_PUBLISH_TOKEN` is what authorizes an upload, and
94
+ a maintainer holding it can already publish.
95
+
96
+ Actions checks out a detached `HEAD` with no upstream configured, so there the
97
+ publisher proves the same invariant against `GITHUB_REF` instead — the ref must
98
+ be a branch, `origin` must have it, and it must point at the commit being
99
+ released. Uploads still only run from `master`.
100
+
101
+ The CLI is published by its own dispatch, after the pull request that bumps the
102
+ `albus-sdk` pin and the CLI version has been reviewed and merged: the pin has
103
+ to name an SDK version that is already on PyPI. See
104
+ [`client-release-automation.md`](../../.agents/plans/client-release-automation.md).
105
+
71
106
  ## Copy the release to the public mirror
72
107
 
73
108
  ```bash
@@ -91,9 +126,10 @@ and this one, and closes each issue with the version that carries the fix. A
91
126
  reporter cannot see the private fix, so the release closing the issue is the
92
127
  only signal they get.
93
128
 
94
- Your own `git` and `gh` credentials do the push and the issue closing; there is
95
- no stored mirror token. Pushing `.github/workflows/` to the mirror needs a
96
- credential with workflow scope.
129
+ A maintainer's own `git` and `gh` credentials do the push and the issue
130
+ closing; in Actions they come from the release workflow's token. Pushing
131
+ `.github/workflows/` to the mirror needs a credential allowed to write workflow
132
+ files.
97
133
 
98
134
  The checkout you pass is only read: the release commit is built in a throwaway
99
135
  clone of it, so a rejected push — the likely first-release outcome, since the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "albus-cli"
7
- version = "0.4.1"
7
+ version = "0.7.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.10.0",
16
+ "albus-sdk==0.14.0",
17
17
  "typer>=0.26.8,<0.28",
18
18
  ]
19
19
 
@@ -18,6 +18,11 @@ from albus_cli.credentials import Credential
18
18
 
19
19
  API_KEY_ENV = "ALBUS_API_KEY"
20
20
  BASE_URL_ENV = "ALBUS_BASE_URL"
21
+ ORGANIZATION_ENV = "ALBUS_ORG"
22
+
23
+ # Selects among the signed-in user's organizations. An API key is bound to
24
+ # one already, and the server ignores the header for it.
25
+ ORGANIZATION_HEADER = "X-Albus-Organization"
21
26
 
22
27
  # Renew slightly early: an access token that outlives the request it is
23
28
  # sent on is not worth the 401.
@@ -53,22 +58,29 @@ def api_key() -> str | None:
53
58
  return os.environ.get(API_KEY_ENV)
54
59
 
55
60
 
56
- def client(base_url: str, timeout: float | None) -> Albus:
57
- """Build an SDK client. A timeout of None disables the read timeout."""
61
+ def client(
62
+ base_url: str, timeout: float | None, organization: str | None
63
+ ) -> Albus:
64
+ """Build an SDK client. A timeout of None disables the read timeout;
65
+ an organization selects which of the user's the requests act in."""
58
66
  key = api_key()
59
67
  if key:
60
68
  # The environment wins over a stored session deliberately, so CI
61
69
  # and agent harnesses inject a credential without touching disk.
62
- return _client(base_url, timeout, api_key=key)
70
+ return _client(base_url, timeout, organization, api_key=key)
63
71
 
64
72
  stored = credentials.load(base_url)
65
73
  if stored is None:
66
74
  raise _signed_out(base_url)
67
75
 
68
- return bearer_client(base_url, timeout, _current(base_url, stored))
76
+ return bearer_client(
77
+ base_url, timeout, organization, _current(base_url, stored)
78
+ )
69
79
 
70
80
 
71
- def signed_in_client(base_url: str, timeout: float | None) -> Albus:
81
+ def signed_in_client(
82
+ base_url: str, timeout: float | None, organization: str | None
83
+ ) -> Albus:
72
84
  """A client on the browser session. The operations Albus accepts
73
85
  only a human bearer token for use it directly: an API key is a 401
74
86
  there, and the environment winning would be a 401 nobody asked
@@ -81,7 +93,9 @@ def signed_in_client(base_url: str, timeout: float | None) -> Albus:
81
93
  "`albus login`."
82
94
  )
83
95
 
84
- return bearer_client(base_url, timeout, _current(base_url, stored))
96
+ return bearer_client(
97
+ base_url, timeout, organization, _current(base_url, stored)
98
+ )
85
99
 
86
100
 
87
101
  def public_client(base_url: str, timeout: float | None) -> Albus:
@@ -94,15 +108,20 @@ def public_client(base_url: str, timeout: float | None) -> Albus:
94
108
  sign-in changes."""
95
109
  global _used
96
110
  _used = None
97
- return _built(base_url, timeout)
111
+ return _built(base_url, timeout, None)
98
112
 
99
113
 
100
114
  def bearer_client(
101
- base_url: str, timeout: float | None, credential: Credential
115
+ base_url: str,
116
+ timeout: float | None,
117
+ organization: str | None,
118
+ credential: Credential,
102
119
  ) -> Albus:
103
120
  """A client on one credential, for `login` reading back the session
104
121
  it just stored rather than whatever precedence would pick."""
105
- return _client(base_url, timeout, access_token=credential.access_token)
122
+ return _client(
123
+ base_url, timeout, organization, access_token=credential.access_token
124
+ )
106
125
 
107
126
 
108
127
  def credential(tokens: oauth.Tokens, replacing: Credential | None) -> Credential:
@@ -206,6 +225,7 @@ def _shadowing(consequence: str) -> str | None:
206
225
  def _client(
207
226
  base_url: str,
208
227
  timeout: float | None,
228
+ organization: str | None,
209
229
  *,
210
230
  api_key: str | None = None,
211
231
  access_token: str | None = None,
@@ -215,6 +235,7 @@ def _client(
215
235
  return _built(
216
236
  base_url,
217
237
  timeout,
238
+ organization,
218
239
  api_key=api_key,
219
240
  access_token=access_token,
220
241
  )
@@ -223,15 +244,19 @@ def _client(
223
244
  def _built(
224
245
  base_url: str,
225
246
  timeout: float | None,
247
+ organization: str | None,
226
248
  *,
227
249
  api_key: str | None = None,
228
250
  access_token: str | None = None,
229
251
  ) -> Albus:
252
+ headers = {ORGANIZATION_HEADER: organization} if organization else {}
230
253
  return Albus(
231
254
  api_key=api_key,
232
255
  access_token=access_token,
233
256
  server_url=base_url,
234
- client=httpx.Client(follow_redirects=True, timeout=timeout),
257
+ client=httpx.Client(
258
+ follow_redirects=True, timeout=timeout, headers=headers
259
+ ),
235
260
  )
236
261
 
237
262
 
@@ -8,9 +8,10 @@ SDK operation: they talk to Auth0 rather than to Albus. See
8
8
  from typing import Annotated
9
9
 
10
10
  import typer
11
+ from albus_sdk import models
11
12
 
12
13
  from albus_cli import client, credentials, docs, oauth, output
13
- from albus_cli.context import base_url, sdk, timeout
14
+ from albus_cli.context import base_url, organization, sdk, timeout
14
15
 
15
16
  NoBrowser = Annotated[
16
17
  bool,
@@ -39,11 +40,13 @@ def login(ctx: typer.Context, no_browser: NoBrowser = False) -> None:
39
40
 
40
41
  # Read the account back through the session just stored, not through
41
42
  # precedence: naming the API key's account would be a false report.
42
- signed_in = client.bearer_client(api, timeout(ctx), session)
43
+ signed_in = client.bearer_client(
44
+ api, timeout(ctx), organization(ctx), session
45
+ )
43
46
  # A browser session authenticates a user, so `/whoami` names one;
44
47
  # an account it did not name is not one to claim under "Signed in".
45
48
  user = signed_in.auth.whoami().user
46
- output.done("Signed in" if user is None else f"Signed in as {user.email}")
49
+ output.done(_signed_in(user))
47
50
  output.field("API", api)
48
51
  output.field("Credential", output.abbreviated(credentials.path()))
49
52
  if session.refresh_token is None:
@@ -69,6 +72,18 @@ def whoami(ctx: typer.Context) -> None:
69
72
  output.emit(sdk(ctx).auth.whoami())
70
73
 
71
74
 
75
+ def _signed_in(user: models.AuthenticatedUser | None) -> str:
76
+ """The account and the organization its commands act in, as Albus
77
+ reports them for the session just stored."""
78
+ if user is None:
79
+ return "Signed in"
80
+
81
+ if user.active_organization is None:
82
+ return f"Signed in as {user.email}"
83
+
84
+ return f"Signed in as {user.email} in {user.active_organization.name}"
85
+
86
+
72
87
  def _next_steps() -> None:
73
88
  """The last lines a successful sign-in prints, where attention is
74
89
  highest: the one command that runs an agent, and the sentence to
@@ -0,0 +1,67 @@
1
+ """`albus billing` — the organization's prepaid credits.
2
+
3
+ `/billing/checkout` is `bearerAuth`-only: an API key cannot spend money, so
4
+ `checkout` takes the browser session with `signed_in_sdk`. Reading the
5
+ balance and the ledger accepts either credential.
6
+ """
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from albus_cli.context import sdk, signed_in_sdk
13
+ from albus_cli.output import emit
14
+
15
+ app = typer.Typer(no_args_is_help=True, help="Read and top up prepaid credits.")
16
+
17
+ After = Annotated[
18
+ str | None,
19
+ typer.Option("--after", help="Pagination cursor from a previous page."),
20
+ ]
21
+ Limit = Annotated[int, typer.Option("--limit", help="Page size.")]
22
+
23
+
24
+ @app.command("balance")
25
+ def balance(ctx: typer.Context) -> None:
26
+ """Read the organization's credit balance in USD."""
27
+ emit(sdk(ctx).billing.get_credit_balance())
28
+
29
+
30
+ @app.command("ledger")
31
+ def ledger(ctx: typer.Context, after: After = None, limit: Limit = 100) -> None:
32
+ """List the credit ledger, newest first."""
33
+ emit(sdk(ctx).billing.list_credit_ledger(after=after, limit=limit))
34
+
35
+
36
+ @app.command("checkout")
37
+ def checkout(
38
+ ctx: typer.Context,
39
+ amount_usd: Annotated[
40
+ int,
41
+ typer.Argument(
42
+ metavar="AMOUNT_USD", help="Whole US dollars of credit to buy."
43
+ ),
44
+ ],
45
+ success_url: Annotated[
46
+ str,
47
+ typer.Option(
48
+ "--success-url",
49
+ help="Where the payment page sends the buyer after paying.",
50
+ ),
51
+ ],
52
+ cancel_url: Annotated[
53
+ str,
54
+ typer.Option(
55
+ "--cancel-url",
56
+ help="Where the payment page sends the buyer who backs out.",
57
+ ),
58
+ ],
59
+ ) -> None:
60
+ """Start a credit purchase and print the payment page URL."""
61
+ emit(
62
+ signed_in_sdk(ctx).billing.create_checkout(
63
+ amount_usd=amount_usd,
64
+ success_url=success_url,
65
+ cancel_url=cancel_url,
66
+ )
67
+ )
@@ -0,0 +1,54 @@
1
+ """`albus invites` — the pending invitations into the active organization.
2
+
3
+ `/organization/invites` is `bearerAuth`-only, like `/tokens`: an API key
4
+ cannot invite a user, so these commands take the browser session with
5
+ `signed_in_sdk`.
6
+ """
7
+
8
+ from typing import Annotated, Literal
9
+
10
+ import typer
11
+
12
+ from albus_cli.context import signed_in_sdk
13
+ from albus_cli.output import emit
14
+
15
+ app = typer.Typer(no_args_is_help=True, help="Invite users by email.")
16
+
17
+ Role = Literal["admin", "member"]
18
+
19
+ InviteID = Annotated[
20
+ str, typer.Argument(metavar="ID", help="The invitation ID.")
21
+ ]
22
+
23
+
24
+ @app.command("list")
25
+ def list_invites(ctx: typer.Context) -> None:
26
+ """List pending invitations. Requires the admin role."""
27
+ emit(signed_in_sdk(ctx).invites.list_invites())
28
+
29
+
30
+ @app.command("create")
31
+ def create(
32
+ ctx: typer.Context,
33
+ email: Annotated[
34
+ str,
35
+ typer.Argument(
36
+ metavar="EMAIL", help="Email address of the person to invite."
37
+ ),
38
+ ],
39
+ role: Annotated[
40
+ Role | None,
41
+ typer.Option(
42
+ "--role",
43
+ help="Role the invitee joins with. Defaults to member.",
44
+ ),
45
+ ] = None,
46
+ ) -> None:
47
+ """Invite a user to join your active organization."""
48
+ emit(signed_in_sdk(ctx).invites.create_invite(email=email, role=role))
49
+
50
+
51
+ @app.command("revoke")
52
+ def revoke(ctx: typer.Context, invite_id: InviteID) -> None:
53
+ """Revoke a pending invitation. Requires the admin role."""
54
+ signed_in_sdk(ctx).invites.revoke_invite(id=invite_id)