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.
Files changed (77) hide show
  1. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/PKG-INFO +13 -6
  2. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/README.md +12 -5
  3. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/api_client.py +297 -4
  4. dailybot_cli-3.29.0/dailybot_cli/commands/_channels.py +206 -0
  5. dailybot_cli-3.29.0/dailybot_cli/commands/_device_login.py +165 -0
  6. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/auth.py +132 -18
  7. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/chat.py +2 -0
  8. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/identity.py +6 -1
  9. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/interactive.py +22 -4
  10. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/public_api_helpers.py +172 -6
  11. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/config.py +167 -12
  12. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/display.py +82 -0
  13. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/PKG-INFO +13 -6
  14. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/SOURCES.txt +1 -0
  15. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/pyproject.toml +1 -1
  16. dailybot_cli-3.28.1/dailybot_cli/commands/_channels.py +0 -80
  17. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/LICENSE +0 -0
  18. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/__init__.py +0 -0
  19. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/__init__.py +0 -0
  20. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_attachments.py +0 -0
  21. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_beta.py +0 -0
  22. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_briefing.py +0 -0
  23. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_destructive.py +0 -0
  24. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_favorites.py +0 -0
  25. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_outbound.py +0 -0
  26. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_paging.py +0 -0
  27. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_refs.py +0 -0
  28. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_rollups.py +0 -0
  29. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_schedule.py +0 -0
  30. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_sorting.py +0 -0
  31. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/_writes.py +0 -0
  32. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/agent.py +0 -0
  33. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/ask.py +0 -0
  34. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/authoring_helpers.py +0 -0
  35. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/board.py +0 -0
  36. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/channels.py +0 -0
  37. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/checkin.py +0 -0
  38. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/config.py +0 -0
  39. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/conversation.py +0 -0
  40. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/env.py +0 -0
  41. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/featured.py +0 -0
  42. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/form.py +0 -0
  43. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/goal.py +0 -0
  44. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/hook.py +0 -0
  45. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/interactive_chat.py +0 -0
  46. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/kudos.py +0 -0
  47. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/label.py +0 -0
  48. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan.py +0 -0
  49. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan_label.py +0 -0
  50. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/plan_views.py +0 -0
  51. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/project.py +0 -0
  52. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/query_options.py +0 -0
  53. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/status.py +0 -0
  54. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/task.py +0 -0
  55. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/tasks.py +0 -0
  56. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/tasks_settings.py +0 -0
  57. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/team.py +0 -0
  58. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/uninstall.py +0 -0
  59. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/update.py +0 -0
  60. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/upgrade.py +0 -0
  61. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/user.py +0 -0
  62. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/user_scoped_actions.py +0 -0
  63. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/version.py +0 -0
  64. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/commands/workflow.py +0 -0
  65. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/install_method.py +0 -0
  66. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/ledger.py +0 -0
  67. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/main.py +0 -0
  68. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/__init__.py +0 -0
  69. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/app.py +0 -0
  70. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/commands.py +0 -0
  71. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/conversation.py +0 -0
  72. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli/tui/intents.py +0 -0
  73. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/dependency_links.txt +0 -0
  74. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/entry_points.txt +0 -0
  75. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/requires.txt +0 -0
  76. {dailybot_cli-3.28.1 → dailybot_cli-3.29.0}/dailybot_cli.egg-info/top_level.txt +0 -0
  77. {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.28.1
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 token |
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 token |
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
- return self._dispatch_http(method, url, **kwargs)
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 ---