dailybot-cli 3.28.1__tar.gz → 3.29.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.
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/PKG-INFO +13 -6
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/README.md +12 -5
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/api_client.py +297 -4
- dailybot_cli-3.29.0/dailybot_cli/commands/_channels.py +206 -0
- dailybot_cli-3.29.0/dailybot_cli/commands/_device_login.py +165 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/auth.py +132 -18
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/chat.py +2 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/identity.py +6 -1
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/interactive.py +22 -4
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/public_api_helpers.py +172 -6
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/config.py +167 -12
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/display.py +82 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/PKG-INFO +13 -6
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/SOURCES.txt +1 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/pyproject.toml +1 -1
- dailybot_cli-3.28.1/dailybot_cli/commands/_channels.py +0 -80
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/LICENSE +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/__init__.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/__init__.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_attachments.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_beta.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_briefing.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_destructive.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_favorites.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_outbound.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_paging.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_refs.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_rollups.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_schedule.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_sorting.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_writes.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/agent.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/ask.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/authoring_helpers.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/board.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/channels.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/checkin.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/config.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/conversation.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/env.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/featured.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/form.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/goal.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/hook.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/interactive_chat.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/kudos.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/label.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan_label.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan_views.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/project.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/query_options.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/status.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/task.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/tasks.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/tasks_settings.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/team.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/uninstall.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/update.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/upgrade.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/user.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/user_scoped_actions.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/version.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/workflow.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/install_method.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/ledger.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/main.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/__init__.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/app.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/commands.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/conversation.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/intents.py +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/dependency_links.txt +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/entry_points.txt +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/requires.txt +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/top_level.txt +0 -0
- {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dailybot-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.29.0
|
|
4
4
|
Summary: Dailybot CLI - The command-line bridge between humans and agents. Progress reports, observability, and workflow automation for modern teams across Slack, Google Chat, Discord, MS Teams, and more.
|
|
5
5
|
Author-email: Dailybot <support@dailybot.com>
|
|
6
6
|
License: MIT
|
|
@@ -193,6 +193,9 @@ Authenticate once with your Dailybot email, then submit updates, complete check-
|
|
|
193
193
|
# Log in (interactive, email OTP)
|
|
194
194
|
dailybot login
|
|
195
195
|
|
|
196
|
+
# Or sign in through the browser: approve a short code at a Dailybot link
|
|
197
|
+
dailybot login --device
|
|
198
|
+
|
|
196
199
|
# See what check-ins are waiting for you
|
|
197
200
|
dailybot status
|
|
198
201
|
|
|
@@ -661,6 +664,10 @@ dailybot config key=your-key
|
|
|
661
664
|
dailybot login
|
|
662
665
|
```
|
|
663
666
|
|
|
667
|
+
### Browser sign-in (`--device`)
|
|
668
|
+
|
|
669
|
+
`dailybot login --device` prints a Dailybot link and a short code such as `BCDF-GHJK`. Open the link in any browser where you use Dailybot (it does not have to be on the same machine, so it works over SSH), check that the page shows the same code, and approve it. The CLI waits at the interval the server asks for and saves the session as soon as you approve. The session renews itself while you use the CLI, so you do not have to log in again every few hours. Denying the request in the browser, or letting the code expire (10 minutes), stops the command without changing your stored session. On a Dailybot server that does not offer browser sign-in yet, the command continues with the email code.
|
|
670
|
+
|
|
664
671
|
### Non-interactive login
|
|
665
672
|
|
|
666
673
|
AI agents (e.g. Claude Code) and scripts can log in without interactive prompts using a two-step flow:
|
|
@@ -877,11 +884,11 @@ Replies to agent emails land as messages retrievable via `dailybot agent message
|
|
|
877
884
|
|
|
878
885
|
| Command | Description |
|
|
879
886
|
|---------|-------------|
|
|
880
|
-
| `dailybot login` | Authenticate with email OTP |
|
|
881
|
-
| `dailybot logout` | Log out and revoke
|
|
887
|
+
| `dailybot login` | Authenticate with email OTP, or in the browser with `--device` |
|
|
888
|
+
| `dailybot logout` | Log out and revoke the session (both tokens of a browser sign-in) |
|
|
882
889
|
| `dailybot status` | Show pending check-ins and auth status |
|
|
883
890
|
| `dailybot me` | Show the authenticated user and org context (`--include-email`, `--json`) |
|
|
884
|
-
| `dailybot org` | Show the organization the credential is scoped to (`--json`) |
|
|
891
|
+
| `dailybot org` | Show the organization the credential is scoped to, and its chat platforms when the server provides them (`--json`) |
|
|
885
892
|
| `dailybot update` | Submit a free-text or structured check-in update |
|
|
886
893
|
| `dailybot config` | Get, set, or remove a stored setting (e.g. API key) |
|
|
887
894
|
| `dailybot version` | Show version info and optionally check for updates |
|
|
@@ -984,7 +991,7 @@ humans who own them.
|
|
|
984
991
|
| `dailybot plan <group> ...` | The one root of **Dailybot Plan** (API root `/v1/plan/`): the `tasks`, `task`, `board`, `project` and `goal` groups all live under it, e.g. `dailybot plan tasks status` |
|
|
985
992
|
| `dailybot plan tasks timeline` | Dated work in a window: the goals that overlap it, the tasks with start or due dates, the milestones in the window and the projects overlapping it (`--since` / `--until` / `--date` / `--today` / `--last-week`, `--include-unscheduled`, repeatable `--project` and `--milestone`). One document, not a paged list |
|
|
986
993
|
| `dailybot plan tasks notifications catalog` · `get` · `set` | Personal notification preferences: the kinds (`catalog`), your kind × chat × email matrix and destination (`get`), and a partial `set` (`--kind` repeatable with `--chat/--no-chat`, `--email/--no-email`, and `--dm` or `--channel <name\|external id>`, public channels only). **Needs a person: `dailybot login` or a personal API key** |
|
|
987
|
-
| `dailybot plan tasks channels search [-q <text>] [--type public]` | The chat-platform channels you can pick (admins also see the private ones the bot is in); the external id it shows is what routes, reports and `chat send --channel` take. Not `channels list` (report channels) |
|
|
994
|
+
| `dailybot plan tasks channels search [-q <text>] [--type public]` | The chat-platform channels you can pick (admins also see the private ones the bot is in); the external id it shows is what routes, reports and `chat send --channel` take (optionally qualified as `slack:<id>` or `<platform-id>:<id>` on organizations with several chat platforms). Not `channels list` (report channels) |
|
|
988
995
|
| `dailybot plan tasks routes list` · `get` · `create` · `update` · `delete` · `send-test` · `deliveries` | Post organization events (card created/completed/blocked, project health or lead changed, milestone reached, ...) to a channel: `create --name --channel --kind ...` with optional `--board` / `--project` scope. **Admins write, members read.** `send-test` always previews with a dry run first and posts only after confirmation or `--yes`; `delete` states its consequence |
|
|
989
996
|
| `dailybot plan tasks reports list` · `get` · `create` · `update` · `delete` · `preview` · `send-test` · `runs` | Scheduled Tasks digests (`daily`, `week_start`, `week_end`) to a channel and/or by email: `--weekdays mon,fri` (weekly kinds take exactly one), `--time 09:00`, `--timezone` (yours by default), `--email-to <name\|uuid>`. `preview` is the exact document; `send-test` is dry-run first. Admins write, members read |
|
|
990
997
|
| `dailybot plan tasks briefing get` · `set` · `preview` · `send-test` | Your personal daily briefing (overdue, due today, in progress, blocked, mentions, projects you lead), by DM and/or email, on chosen weekdays and time. Person doors; `send-test` is dry-run first |
|
|
@@ -1122,7 +1129,7 @@ Works headless for agents (`--json` prints the `bot_message_id`).
|
|
|
1122
1129
|
|
|
1123
1130
|
| Command | Description |
|
|
1124
1131
|
|---------|-------------|
|
|
1125
|
-
| `dailybot chat send` | Send a bot message to users (`-u`), channels (`-c`), and/or teams (`-t`) |
|
|
1132
|
+
| `dailybot chat send` | Send a bot message to users (`-u`), channels (`-c`, a channel id or a qualified `slack:<id>` / `<platform-id>:<id>`), and/or teams (`-t`) |
|
|
1126
1133
|
| `dailybot chat update <bot_message_id>` | Edit a previously sent message (parent **or** a thread reply) |
|
|
1127
1134
|
| `dailybot conversation open` | Open (or reuse) a Slack group DM with the bot; optionally post a message (`-m`) |
|
|
1128
1135
|
|
|
@@ -154,6 +154,9 @@ Authenticate once with your Dailybot email, then submit updates, complete check-
|
|
|
154
154
|
# Log in (interactive, email OTP)
|
|
155
155
|
dailybot login
|
|
156
156
|
|
|
157
|
+
# Or sign in through the browser: approve a short code at a Dailybot link
|
|
158
|
+
dailybot login --device
|
|
159
|
+
|
|
157
160
|
# See what check-ins are waiting for you
|
|
158
161
|
dailybot status
|
|
159
162
|
|
|
@@ -622,6 +625,10 @@ dailybot config key=your-key
|
|
|
622
625
|
dailybot login
|
|
623
626
|
```
|
|
624
627
|
|
|
628
|
+
### Browser sign-in (`--device`)
|
|
629
|
+
|
|
630
|
+
`dailybot login --device` prints a Dailybot link and a short code such as `BCDF-GHJK`. Open the link in any browser where you use Dailybot (it does not have to be on the same machine, so it works over SSH), check that the page shows the same code, and approve it. The CLI waits at the interval the server asks for and saves the session as soon as you approve. The session renews itself while you use the CLI, so you do not have to log in again every few hours. Denying the request in the browser, or letting the code expire (10 minutes), stops the command without changing your stored session. On a Dailybot server that does not offer browser sign-in yet, the command continues with the email code.
|
|
631
|
+
|
|
625
632
|
### Non-interactive login
|
|
626
633
|
|
|
627
634
|
AI agents (e.g. Claude Code) and scripts can log in without interactive prompts using a two-step flow:
|
|
@@ -838,11 +845,11 @@ Replies to agent emails land as messages retrievable via `dailybot agent message
|
|
|
838
845
|
|
|
839
846
|
| Command | Description |
|
|
840
847
|
|---------|-------------|
|
|
841
|
-
| `dailybot login` | Authenticate with email OTP |
|
|
842
|
-
| `dailybot logout` | Log out and revoke
|
|
848
|
+
| `dailybot login` | Authenticate with email OTP, or in the browser with `--device` |
|
|
849
|
+
| `dailybot logout` | Log out and revoke the session (both tokens of a browser sign-in) |
|
|
843
850
|
| `dailybot status` | Show pending check-ins and auth status |
|
|
844
851
|
| `dailybot me` | Show the authenticated user and org context (`--include-email`, `--json`) |
|
|
845
|
-
| `dailybot org` | Show the organization the credential is scoped to (`--json`) |
|
|
852
|
+
| `dailybot org` | Show the organization the credential is scoped to, and its chat platforms when the server provides them (`--json`) |
|
|
846
853
|
| `dailybot update` | Submit a free-text or structured check-in update |
|
|
847
854
|
| `dailybot config` | Get, set, or remove a stored setting (e.g. API key) |
|
|
848
855
|
| `dailybot version` | Show version info and optionally check for updates |
|
|
@@ -945,7 +952,7 @@ humans who own them.
|
|
|
945
952
|
| `dailybot plan <group> ...` | The one root of **Dailybot Plan** (API root `/v1/plan/`): the `tasks`, `task`, `board`, `project` and `goal` groups all live under it, e.g. `dailybot plan tasks status` |
|
|
946
953
|
| `dailybot plan tasks timeline` | Dated work in a window: the goals that overlap it, the tasks with start or due dates, the milestones in the window and the projects overlapping it (`--since` / `--until` / `--date` / `--today` / `--last-week`, `--include-unscheduled`, repeatable `--project` and `--milestone`). One document, not a paged list |
|
|
947
954
|
| `dailybot plan tasks notifications catalog` · `get` · `set` | Personal notification preferences: the kinds (`catalog`), your kind × chat × email matrix and destination (`get`), and a partial `set` (`--kind` repeatable with `--chat/--no-chat`, `--email/--no-email`, and `--dm` or `--channel <name\|external id>`, public channels only). **Needs a person: `dailybot login` or a personal API key** |
|
|
948
|
-
| `dailybot plan tasks channels search [-q <text>] [--type public]` | The chat-platform channels you can pick (admins also see the private ones the bot is in); the external id it shows is what routes, reports and `chat send --channel` take. Not `channels list` (report channels) |
|
|
955
|
+
| `dailybot plan tasks channels search [-q <text>] [--type public]` | The chat-platform channels you can pick (admins also see the private ones the bot is in); the external id it shows is what routes, reports and `chat send --channel` take (optionally qualified as `slack:<id>` or `<platform-id>:<id>` on organizations with several chat platforms). Not `channels list` (report channels) |
|
|
949
956
|
| `dailybot plan tasks routes list` · `get` · `create` · `update` · `delete` · `send-test` · `deliveries` | Post organization events (card created/completed/blocked, project health or lead changed, milestone reached, ...) to a channel: `create --name --channel --kind ...` with optional `--board` / `--project` scope. **Admins write, members read.** `send-test` always previews with a dry run first and posts only after confirmation or `--yes`; `delete` states its consequence |
|
|
950
957
|
| `dailybot plan tasks reports list` · `get` · `create` · `update` · `delete` · `preview` · `send-test` · `runs` | Scheduled Tasks digests (`daily`, `week_start`, `week_end`) to a channel and/or by email: `--weekdays mon,fri` (weekly kinds take exactly one), `--time 09:00`, `--timezone` (yours by default), `--email-to <name\|uuid>`. `preview` is the exact document; `send-test` is dry-run first. Admins write, members read |
|
|
951
958
|
| `dailybot plan tasks briefing get` · `set` · `preview` · `send-test` | Your personal daily briefing (overdue, due today, in progress, blocked, mentions, projects you lead), by DM and/or email, on chosen weekdays and time. Person doors; `send-test` is dry-run first |
|
|
@@ -1083,7 +1090,7 @@ Works headless for agents (`--json` prints the `bot_message_id`).
|
|
|
1083
1090
|
|
|
1084
1091
|
| Command | Description |
|
|
1085
1092
|
|---------|-------------|
|
|
1086
|
-
| `dailybot chat send` | Send a bot message to users (`-u`), channels (`-c`), and/or teams (`-t`) |
|
|
1093
|
+
| `dailybot chat send` | Send a bot message to users (`-u`), channels (`-c`, a channel id or a qualified `slack:<id>` / `<platform-id>:<id>`), and/or teams (`-t`) |
|
|
1087
1094
|
| `dailybot chat update <bot_message_id>` | Edit a previously sent message (parent **or** a thread reply) |
|
|
1088
1095
|
| `dailybot conversation open` | Open (or reuse) a Slack group DM with the bot; optionally post a message (`-m`) |
|
|
1089
1096
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""HTTP client for Dailybot CLI API endpoints."""
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
+
import os
|
|
4
5
|
import platform
|
|
5
6
|
import re
|
|
6
7
|
import time
|
|
@@ -22,6 +23,9 @@ from dailybot_cli.config import (
|
|
|
22
23
|
get_api_key_source,
|
|
23
24
|
get_api_url,
|
|
24
25
|
get_login_token_for,
|
|
26
|
+
get_refresh_token_for,
|
|
27
|
+
text_or_none,
|
|
28
|
+
update_token_pair,
|
|
25
29
|
)
|
|
26
30
|
from dailybot_cli.install_method import detect_install_method
|
|
27
31
|
|
|
@@ -55,12 +59,22 @@ def user_agent() -> str:
|
|
|
55
59
|
return f"{USER_AGENT_PRODUCT}/{__version__} (python/{python}; {os_name}; {method})"
|
|
56
60
|
|
|
57
61
|
|
|
62
|
+
# Telemetry headers (DEVICE_FLOW_WIRE, W1): the server counts clients and versions
|
|
63
|
+
# from them. They never affect authorization, throttling or responses, and they go
|
|
64
|
+
# only to the Dailybot API (never to a presigned storage host).
|
|
65
|
+
CLIENT_HEADER: str = "X-Dailybot-Client"
|
|
66
|
+
CLIENT_VERSION_HEADER: str = "X-Dailybot-Client-Version"
|
|
67
|
+
CLIENT_HEADER_VALUE: str = "cli"
|
|
68
|
+
|
|
69
|
+
|
|
58
70
|
def _base_headers() -> dict[str, str]:
|
|
59
71
|
"""The headers every Dailybot API request carries, before any credential."""
|
|
60
72
|
return {
|
|
61
73
|
"Content-Type": "application/json",
|
|
62
74
|
"Accept": "application/json",
|
|
63
75
|
"User-Agent": user_agent(),
|
|
76
|
+
CLIENT_HEADER: CLIENT_HEADER_VALUE,
|
|
77
|
+
CLIENT_VERSION_HEADER: __version__,
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
|
|
@@ -72,6 +86,25 @@ def _base_headers() -> dict[str, str]:
|
|
|
72
86
|
# session work seamlessly regardless of which convention the server uses.
|
|
73
87
|
_AUTH_RETRY_STATUS_CODES: frozenset[int] = frozenset({401, 403})
|
|
74
88
|
|
|
89
|
+
# RFC 8628 device flow (PLAN_002, frozen at checkpoint 1: DEVICE_FLOW_WIRE).
|
|
90
|
+
DEVICE_CLIENT_ID: str = "dailybot-cli"
|
|
91
|
+
DEVICE_GRANT_TYPE: str = "urn:ietf:params:oauth:grant-type:device_code"
|
|
92
|
+
# The only 401 that refreshes a device-flow pair (L36). Every other 401 code
|
|
93
|
+
# (refresh_token_expired, user_disabled, invalid_credentials, session_revoked)
|
|
94
|
+
# keeps the long-standing behaviour: no refresh loop.
|
|
95
|
+
ACCESS_TOKEN_EXPIRED_CODE: str = "access_token_expired"
|
|
96
|
+
SESSION_REVOKED_CODE: str = "session_revoked"
|
|
97
|
+
HTTP_UNAUTHORIZED: int = 401
|
|
98
|
+
|
|
99
|
+
# 503 auth_backend_unavailable (CONTRACT_SESSION §7, W1, L38): the server's auth
|
|
100
|
+
# backend is briefly down. Transient by definition: retried with a bounded wait,
|
|
101
|
+
# never treated as an expired session, a refresh trigger or a credential fallback.
|
|
102
|
+
AUTH_BACKEND_UNAVAILABLE_CODE: str = "auth_backend_unavailable"
|
|
103
|
+
HTTP_SERVICE_UNAVAILABLE: int = 503
|
|
104
|
+
AUTH_BACKEND_MAX_RETRIES: int = 3
|
|
105
|
+
AUTH_BACKEND_MAX_WAIT_SECS: float = 5.0
|
|
106
|
+
AUTH_BACKEND_DEFAULT_RETRY_AFTER_SECS: float = 2.0
|
|
107
|
+
|
|
75
108
|
# The person-shaped Tasks doors have a third refusal shape: `400 actor_required`,
|
|
76
109
|
# meaning "this credential is an organization with nobody to be". It is the same
|
|
77
110
|
# condition as a 401 — the wrong *kind* of credential was presented — but it does
|
|
@@ -393,6 +426,48 @@ class APIError(Exception):
|
|
|
393
426
|
super().__init__(f"API error {status_code}: {detail}")
|
|
394
427
|
|
|
395
428
|
|
|
429
|
+
def _json_field(response: httpx.Response, key: str) -> Any:
|
|
430
|
+
"""One top-level field of a JSON body, or ``None`` when the body is not a JSON object."""
|
|
431
|
+
try:
|
|
432
|
+
body: Any = response.json()
|
|
433
|
+
except Exception:
|
|
434
|
+
return None
|
|
435
|
+
return body.get(key) if isinstance(body, dict) else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _is_auth_backend_unavailable(response: httpx.Response) -> bool:
|
|
439
|
+
return (
|
|
440
|
+
response.status_code == HTTP_SERVICE_UNAVAILABLE
|
|
441
|
+
and _json_field(response, "code") == AUTH_BACKEND_UNAVAILABLE_CODE
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _auth_backend_wait(response: httpx.Response) -> float:
|
|
446
|
+
"""``Retry-After`` in seconds, defaulted when absent or unreadable, capped at 5 s."""
|
|
447
|
+
raw: Any = response.headers.get("Retry-After")
|
|
448
|
+
try:
|
|
449
|
+
wait: float = float(raw) if raw is not None else AUTH_BACKEND_DEFAULT_RETRY_AFTER_SECS
|
|
450
|
+
except (TypeError, ValueError):
|
|
451
|
+
wait = AUTH_BACKEND_DEFAULT_RETRY_AFTER_SECS
|
|
452
|
+
return min(max(wait, 0.0), AUTH_BACKEND_MAX_WAIT_SECS)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _retry_auth_backend(send: Callable[[], httpx.Response]) -> httpx.Response:
|
|
456
|
+
"""Send, retrying a ``503 auth_backend_unavailable`` up to the bounded budget.
|
|
457
|
+
|
|
458
|
+
The refusal comes from the authentication layer, before any view runs, so a
|
|
459
|
+
retried write is never applied twice. ``time.sleep`` is referenced through the
|
|
460
|
+
module so tests can patch it.
|
|
461
|
+
"""
|
|
462
|
+
response: httpx.Response = send()
|
|
463
|
+
attempt: int = 0
|
|
464
|
+
while _is_auth_backend_unavailable(response) and attempt < AUTH_BACKEND_MAX_RETRIES:
|
|
465
|
+
time.sleep(_auth_backend_wait(response))
|
|
466
|
+
attempt += 1
|
|
467
|
+
response = send()
|
|
468
|
+
return response
|
|
469
|
+
|
|
470
|
+
|
|
396
471
|
def _invalid_identifier() -> APIError:
|
|
397
472
|
"""The refusal for an identifier that would change which door a path reaches.
|
|
398
473
|
|
|
@@ -591,6 +666,16 @@ class DailyBotClient:
|
|
|
591
666
|
lambda: httpx.request(method, url, **kwargs), method=method
|
|
592
667
|
)
|
|
593
668
|
|
|
669
|
+
# At most one refresh per request: before the alternate credential when the
|
|
670
|
+
# Bearer went first, or after it when the Bearer was the fallback (a repo key
|
|
671
|
+
# preferred on the login's own host was refused).
|
|
672
|
+
refresh_tried: bool = self._needs_refresh(response)
|
|
673
|
+
if refresh_tried and self._refresh_session():
|
|
674
|
+
kwargs["headers"] = self._agent_headers()
|
|
675
|
+
response = self._guard_transport(
|
|
676
|
+
lambda: httpx.request(method, url, **kwargs), method=method
|
|
677
|
+
)
|
|
678
|
+
|
|
594
679
|
if _is_auth_retryable(response):
|
|
595
680
|
alt: dict[str, str] | None = self._alt_auth_headers()
|
|
596
681
|
if alt is not None:
|
|
@@ -599,6 +684,12 @@ class DailyBotClient:
|
|
|
599
684
|
lambda: httpx.request(method, url, **kwargs), method=method
|
|
600
685
|
)
|
|
601
686
|
|
|
687
|
+
if not refresh_tried and self._needs_refresh(response) and self._refresh_session():
|
|
688
|
+
kwargs["headers"] = self._bearer_headers()
|
|
689
|
+
response = self._guard_transport(
|
|
690
|
+
lambda: httpx.request(method, url, **kwargs), method=method
|
|
691
|
+
)
|
|
692
|
+
|
|
602
693
|
return response
|
|
603
694
|
|
|
604
695
|
def _request(
|
|
@@ -661,6 +752,12 @@ class DailyBotClient:
|
|
|
661
752
|
|
|
662
753
|
response: httpx.Response = self._dispatch_guarded(method, url, **kwargs)
|
|
663
754
|
|
|
755
|
+
# At most one refresh per request (see `_agent_request`).
|
|
756
|
+
refresh_tried: bool = self._needs_refresh(response)
|
|
757
|
+
if refresh_tried and self._refresh_session():
|
|
758
|
+
kwargs["headers"] = self._replay_headers(self._headers(), files, content, extra_headers)
|
|
759
|
+
response = self._dispatch_guarded(method, url, **kwargs)
|
|
760
|
+
|
|
664
761
|
if _is_auth_retryable(response) and not self._is_final_person_refusal(url, response):
|
|
665
762
|
alt: dict[str, str] | None = self._alt_auth_headers()
|
|
666
763
|
if alt is not None:
|
|
@@ -674,8 +771,103 @@ class DailyBotClient:
|
|
|
674
771
|
kwargs["headers"] = retry_headers
|
|
675
772
|
response = self._dispatch_guarded(method, url, **kwargs)
|
|
676
773
|
|
|
774
|
+
if not refresh_tried and self._needs_refresh(response) and self._refresh_session():
|
|
775
|
+
kwargs["headers"] = self._replay_headers(
|
|
776
|
+
self._bearer_headers(), files, content, extra_headers
|
|
777
|
+
)
|
|
778
|
+
response = self._dispatch_guarded(method, url, **kwargs)
|
|
779
|
+
|
|
677
780
|
return response
|
|
678
781
|
|
|
782
|
+
@staticmethod
|
|
783
|
+
def _replay_headers(
|
|
784
|
+
headers: dict[str, str],
|
|
785
|
+
files: dict[str, Any] | None,
|
|
786
|
+
content: bytes | None,
|
|
787
|
+
extra_headers: dict[str, str] | None,
|
|
788
|
+
) -> dict[str, str]:
|
|
789
|
+
"""Headers for a replay, with the same body rules as the first attempt."""
|
|
790
|
+
replay: dict[str, str] = dict(headers)
|
|
791
|
+
if files is not None or content is not None:
|
|
792
|
+
replay.pop("Content-Type", None)
|
|
793
|
+
if extra_headers:
|
|
794
|
+
replay.update(extra_headers)
|
|
795
|
+
return replay
|
|
796
|
+
|
|
797
|
+
def _bearer_headers(self) -> dict[str, str]:
|
|
798
|
+
"""The login session's Bearer alone: the replay after a refresh that followed a
|
|
799
|
+
fallback to the session, so the refused repo key is not sent again."""
|
|
800
|
+
headers: dict[str, str] = _base_headers()
|
|
801
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
802
|
+
self._agent_auth_mode = "bearer"
|
|
803
|
+
return headers
|
|
804
|
+
|
|
805
|
+
def _needs_refresh(self, response: httpx.Response) -> bool:
|
|
806
|
+
"""A Bearer request refused with ``401 access_token_expired`` (and nothing else)."""
|
|
807
|
+
return (
|
|
808
|
+
response.status_code == HTTP_UNAUTHORIZED
|
|
809
|
+
and self._agent_auth_mode == "bearer"
|
|
810
|
+
and _json_field(response, "code") == ACCESS_TOKEN_EXPIRED_CODE
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def _refresh_session(self) -> bool:
|
|
814
|
+
"""Rotate the stored device-flow pair once; ``True`` when a new access is in hand.
|
|
815
|
+
|
|
816
|
+
Only the session ``dailybot login`` stored for this host refreshes: a legacy
|
|
817
|
+
token, a ``DAILYBOT_CLI_TOKEN`` and a pair issued by another host never do.
|
|
818
|
+
If another CLI process already rotated the file, its access is adopted
|
|
819
|
+
without a second refresh. On ``session_revoked`` the file is re-read once and
|
|
820
|
+
the refresh found there is tried if it differs (L36). Any other refusal
|
|
821
|
+
returns ``False``, so the caller keeps today's 401 handling. A transport
|
|
822
|
+
failure or a non-401 answer is raised instead: an infrastructure problem is
|
|
823
|
+
never a reason to fall back to another credential or to touch the session.
|
|
824
|
+
"""
|
|
825
|
+
if os.environ.get("DAILYBOT_CLI_TOKEN"):
|
|
826
|
+
return False
|
|
827
|
+
refresh: str | None = get_refresh_token_for(self.api_url)
|
|
828
|
+
if not refresh:
|
|
829
|
+
return False
|
|
830
|
+
stored_access: str | None = get_login_token_for(self.api_url)
|
|
831
|
+
if stored_access and stored_access != self.token:
|
|
832
|
+
self.token = stored_access
|
|
833
|
+
return True
|
|
834
|
+
try:
|
|
835
|
+
pair: dict[str, Any] = self.refresh_token_pair(refresh)
|
|
836
|
+
except APIError as error:
|
|
837
|
+
if error.status_code != HTTP_UNAUTHORIZED:
|
|
838
|
+
raise
|
|
839
|
+
if error.code != SESSION_REVOKED_CODE:
|
|
840
|
+
return False
|
|
841
|
+
reread: str | None = get_refresh_token_for(self.api_url)
|
|
842
|
+
if not reread or reread == refresh:
|
|
843
|
+
return False
|
|
844
|
+
try:
|
|
845
|
+
pair = self.refresh_token_pair(reread)
|
|
846
|
+
except APIError as second:
|
|
847
|
+
if second.status_code != HTTP_UNAUTHORIZED:
|
|
848
|
+
raise
|
|
849
|
+
return False
|
|
850
|
+
finally:
|
|
851
|
+
# The refresh door is unauthenticated; the replay is a Bearer request again.
|
|
852
|
+
self._agent_auth_mode = "bearer"
|
|
853
|
+
# Same bar as login's `_save_pair`: an empty token never replaces a working one.
|
|
854
|
+
access: str | None = text_or_none(pair.get("access"))
|
|
855
|
+
new_refresh: str | None = text_or_none(pair.get("refresh"))
|
|
856
|
+
if access is None or new_refresh is None:
|
|
857
|
+
return False
|
|
858
|
+
persisted: bool = update_token_pair(
|
|
859
|
+
access=access,
|
|
860
|
+
refresh=new_refresh,
|
|
861
|
+
expires_at=text_or_none(pair.get("expires_at")),
|
|
862
|
+
refresh_expires_at=text_or_none(pair.get("refresh_expires_at")),
|
|
863
|
+
)
|
|
864
|
+
if not persisted:
|
|
865
|
+
# The session file vanished mid-refresh (a logout elsewhere): do not keep
|
|
866
|
+
# using a pair that exists only in this process.
|
|
867
|
+
return False
|
|
868
|
+
self.token = access
|
|
869
|
+
return True
|
|
870
|
+
|
|
679
871
|
def _is_final_person_refusal(self, url: str, response: httpx.Response) -> bool:
|
|
680
872
|
"""A Tasks door that refused the signed-in person keeps that answer.
|
|
681
873
|
|
|
@@ -748,7 +940,7 @@ class DailyBotClient:
|
|
|
748
940
|
of the documented transport exit.
|
|
749
941
|
"""
|
|
750
942
|
try:
|
|
751
|
-
return send
|
|
943
|
+
return _retry_auth_backend(send)
|
|
752
944
|
except httpx.HTTPError as exc:
|
|
753
945
|
if timeout_message and isinstance(exc, httpx.TimeoutException):
|
|
754
946
|
raise TransportTimeout(timeout_message) from exc
|
|
@@ -771,7 +963,9 @@ class DailyBotClient:
|
|
|
771
963
|
isinstance(params, dict) and str(params.get("dry_run")).lower() in {"true", "1"}
|
|
772
964
|
)
|
|
773
965
|
try:
|
|
774
|
-
|
|
966
|
+
# The one server-signalled exception: a 503 auth_backend_unavailable is
|
|
967
|
+
# retried with a bounded wait (`_retry_auth_backend`).
|
|
968
|
+
return _retry_auth_backend(lambda: self._dispatch_http(method, url, **kwargs))
|
|
775
969
|
except httpx.HTTPError as exc:
|
|
776
970
|
# No retry on purpose. The bounded 429 backoff in `_send_with_retry`
|
|
777
971
|
# is the only retry this client has; silently retrying a connection
|
|
@@ -813,6 +1007,11 @@ class DailyBotClient:
|
|
|
813
1007
|
raw_extra: Any = body.get("extra")
|
|
814
1008
|
if isinstance(raw_extra, dict):
|
|
815
1009
|
extra = dict(raw_extra)
|
|
1010
|
+
# The legacy login doors carry a `deprecation` object on 400 bodies
|
|
1011
|
+
# too (W1); surface it so the notice reaches the renderer.
|
|
1012
|
+
raw_deprecation: Any = body.get("deprecation")
|
|
1013
|
+
if isinstance(raw_deprecation, dict):
|
|
1014
|
+
extra["deprecation"] = dict(raw_deprecation)
|
|
816
1015
|
# Plan-gating responses carry upgrade_url at the top level; surface it
|
|
817
1016
|
# in extra so downstream reads a single place.
|
|
818
1017
|
upgrade_url: Any = body.get("upgrade_url")
|
|
@@ -826,7 +1025,7 @@ class DailyBotClient:
|
|
|
826
1025
|
if response.status_code == 401 and self._agent_auth_mode == "bearer":
|
|
827
1026
|
detail = "Session expired. Run 'dailybot login' to re-authenticate."
|
|
828
1027
|
retry_after: float | None = None
|
|
829
|
-
if response.status_code == 429:
|
|
1028
|
+
if response.status_code == 429 or code == AUTH_BACKEND_UNAVAILABLE_CODE:
|
|
830
1029
|
raw_retry: str | None = response.headers.get("Retry-After")
|
|
831
1030
|
if raw_retry:
|
|
832
1031
|
try:
|
|
@@ -1034,18 +1233,93 @@ class DailyBotClient:
|
|
|
1034
1233
|
)
|
|
1035
1234
|
return self._handle_response(response)
|
|
1036
1235
|
|
|
1236
|
+
def start_device_authorization(self) -> dict[str, Any]:
|
|
1237
|
+
"""POST /v1/cli/auth/device/ — start an RFC 8628 device authorization.
|
|
1238
|
+
|
|
1239
|
+
Unauthenticated: the CLI has no credential yet. A 404 or 405 means the
|
|
1240
|
+
server predates the device flow; the caller falls back to the email code.
|
|
1241
|
+
"""
|
|
1242
|
+
response: httpx.Response = self._guard_transport(
|
|
1243
|
+
lambda: httpx.post(
|
|
1244
|
+
f"{self.api_url}/v1/cli/auth/device/",
|
|
1245
|
+
json={"client_id": DEVICE_CLIENT_ID, "client_version": __version__},
|
|
1246
|
+
headers=self._headers(authenticated=False),
|
|
1247
|
+
timeout=self.timeout,
|
|
1248
|
+
),
|
|
1249
|
+
method="POST",
|
|
1250
|
+
timeout_message="The request timed out. Run `dailybot login` again.",
|
|
1251
|
+
)
|
|
1252
|
+
return self._handle_response(response)
|
|
1253
|
+
|
|
1254
|
+
def poll_device_token(self, device_code: str) -> dict[str, Any]:
|
|
1255
|
+
"""POST /v1/cli/auth/device/token/ — redeem an approved device code.
|
|
1256
|
+
|
|
1257
|
+
Each pending state is a 400 whose ``code`` names it (``authorization_pending``,
|
|
1258
|
+
``slow_down``, ``access_denied``, ``expired_token``, ``invalid_grant``,
|
|
1259
|
+
``invalid_client``). They are never retried as authentication failures; a
|
|
1260
|
+
``slow_down`` carries the new polling interval in ``APIError.extra["interval"]``.
|
|
1261
|
+
"""
|
|
1262
|
+
response: httpx.Response = self._guard_transport(
|
|
1263
|
+
lambda: httpx.post(
|
|
1264
|
+
f"{self.api_url}/v1/cli/auth/device/token/",
|
|
1265
|
+
json={
|
|
1266
|
+
"grant_type": DEVICE_GRANT_TYPE,
|
|
1267
|
+
"device_code": device_code,
|
|
1268
|
+
"client_id": DEVICE_CLIENT_ID,
|
|
1269
|
+
},
|
|
1270
|
+
headers=self._headers(authenticated=False),
|
|
1271
|
+
timeout=self.timeout,
|
|
1272
|
+
),
|
|
1273
|
+
method="POST",
|
|
1274
|
+
# A poll changes nothing until the approval exists, and a code that was
|
|
1275
|
+
# redeemed answers invalid_grant: polling again is always safe.
|
|
1276
|
+
timeout_message="The request timed out. Polling again is safe; the code is still valid.",
|
|
1277
|
+
)
|
|
1278
|
+
try:
|
|
1279
|
+
return self._handle_response(response)
|
|
1280
|
+
except APIError as error:
|
|
1281
|
+
interval: Any = _json_field(response, "interval")
|
|
1282
|
+
if isinstance(interval, int) and not isinstance(interval, bool):
|
|
1283
|
+
error.extra["interval"] = interval
|
|
1284
|
+
raise
|
|
1285
|
+
|
|
1286
|
+
def refresh_token_pair(self, refresh: str) -> dict[str, Any]:
|
|
1287
|
+
"""POST /v1/cli/auth/refresh/ — rotate a device-flow session's token pair.
|
|
1288
|
+
|
|
1289
|
+
Unauthenticated: the refresh token in the body is the proof. Failures keep
|
|
1290
|
+
their ``code`` (``session_revoked``, ``refresh_token_expired``, ``user_disabled``).
|
|
1291
|
+
"""
|
|
1292
|
+
response: httpx.Response = self._guard_transport(
|
|
1293
|
+
lambda: httpx.post(
|
|
1294
|
+
f"{self.api_url}/v1/cli/auth/refresh/",
|
|
1295
|
+
json={"refresh": refresh},
|
|
1296
|
+
headers=self._headers(authenticated=False),
|
|
1297
|
+
timeout=self.timeout,
|
|
1298
|
+
),
|
|
1299
|
+
method="POST",
|
|
1300
|
+
)
|
|
1301
|
+
return self._handle_response(response)
|
|
1302
|
+
|
|
1037
1303
|
def auth_status(self) -> dict[str, Any]:
|
|
1038
1304
|
"""GET /v1/cli/auth/status/"""
|
|
1039
1305
|
response: httpx.Response = self._request("GET", f"{self.api_url}/v1/cli/auth/status/")
|
|
1040
1306
|
return self._handle_response(response)
|
|
1041
1307
|
|
|
1042
|
-
def logout(self) -> dict[str, Any]:
|
|
1308
|
+
def logout(self, refresh: str | None = None, *, access_expired: bool = False) -> dict[str, Any]:
|
|
1043
1309
|
"""POST /v1/cli/auth/logout/
|
|
1044
1310
|
|
|
1045
1311
|
Uses ``_headers`` directly (no fallback) because logout is a
|
|
1046
1312
|
Bearer-only lifecycle operation — retrying with an API key would
|
|
1047
1313
|
neither succeed nor be semantically meaningful.
|
|
1314
|
+
|
|
1315
|
+
For a device-flow pair (``refresh`` given) the Bearer form revokes the whole
|
|
1316
|
+
family. When the access token has expired (``access_expired``, or the server
|
|
1317
|
+
answers ``401 access_token_expired``) the unauthenticated ``{"refresh": ...}``
|
|
1318
|
+
body form revokes it instead, without a refresh round-trip. Without
|
|
1319
|
+
``refresh`` this is today's call, byte for byte.
|
|
1048
1320
|
"""
|
|
1321
|
+
if refresh and access_expired:
|
|
1322
|
+
return self._logout_with_refresh(refresh)
|
|
1049
1323
|
response: httpx.Response = self._guard_transport(
|
|
1050
1324
|
lambda: httpx.post(
|
|
1051
1325
|
f"{self.api_url}/v1/cli/auth/logout/",
|
|
@@ -1054,6 +1328,25 @@ class DailyBotClient:
|
|
|
1054
1328
|
),
|
|
1055
1329
|
method="POST",
|
|
1056
1330
|
)
|
|
1331
|
+
if (
|
|
1332
|
+
refresh
|
|
1333
|
+
and response.status_code == HTTP_UNAUTHORIZED
|
|
1334
|
+
and _json_field(response, "code") == ACCESS_TOKEN_EXPIRED_CODE
|
|
1335
|
+
):
|
|
1336
|
+
return self._logout_with_refresh(refresh)
|
|
1337
|
+
return self._handle_response(response)
|
|
1338
|
+
|
|
1339
|
+
def _logout_with_refresh(self, refresh: str) -> dict[str, Any]:
|
|
1340
|
+
"""The unauthenticated logout form: the refresh token in the body is the proof."""
|
|
1341
|
+
response: httpx.Response = self._guard_transport(
|
|
1342
|
+
lambda: httpx.post(
|
|
1343
|
+
f"{self.api_url}/v1/cli/auth/logout/",
|
|
1344
|
+
json={"refresh": refresh},
|
|
1345
|
+
headers=self._headers(authenticated=False),
|
|
1346
|
+
timeout=self.timeout,
|
|
1347
|
+
),
|
|
1348
|
+
method="POST",
|
|
1349
|
+
)
|
|
1057
1350
|
return self._handle_response(response)
|
|
1058
1351
|
|
|
1059
1352
|
# --- Update/Status endpoints ---
|