dailybot-cli 3.27.13__tar.gz → 3.28.1__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.27.13 → dailybot_cli-3.28.1}/PKG-INFO +4 -2
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/README.md +3 -1
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/api_client.py +72 -24
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/agent.py +73 -36
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/public_api_helpers.py +72 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/config.py +43 -15
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/display.py +51 -10
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/PKG-INFO +4 -2
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/pyproject.toml +1 -1
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/LICENSE +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/__init__.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/__init__.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_attachments.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_beta.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_briefing.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_channels.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_destructive.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_favorites.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_outbound.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_paging.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_refs.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_rollups.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_schedule.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_sorting.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/_writes.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/ask.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/auth.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/authoring_helpers.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/board.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/channels.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/chat.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/checkin.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/config.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/conversation.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/env.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/featured.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/form.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/goal.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/hook.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/identity.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/interactive.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/interactive_chat.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/kudos.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/label.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/plan.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/plan_label.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/plan_views.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/project.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/query_options.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/status.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/task.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/tasks.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/tasks_settings.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/team.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/uninstall.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/update.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/upgrade.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/user.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/user_scoped_actions.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/version.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/commands/workflow.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/install_method.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/ledger.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/main.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/tui/__init__.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/tui/app.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/tui/commands.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/tui/conversation.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli/tui/intents.py +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/SOURCES.txt +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/dependency_links.txt +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/entry_points.txt +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/requires.txt +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/dailybot_cli.egg-info/top_level.txt +0 -0
- {dailybot_cli-3.27.13 → dailybot_cli-3.28.1}/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.28.1
|
|
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
|
|
@@ -799,7 +799,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
|
|
|
799
799
|
|
|
800
800
|
This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
|
|
801
801
|
|
|
802
|
-
The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The
|
|
802
|
+
The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
|
|
803
|
+
|
|
804
|
+
The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
|
|
803
805
|
|
|
804
806
|
### Agent commands
|
|
805
807
|
|
|
@@ -760,7 +760,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
|
|
|
760
760
|
|
|
761
761
|
This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
|
|
762
762
|
|
|
763
|
-
The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The
|
|
763
|
+
The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
|
|
764
|
+
|
|
765
|
+
The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
|
|
764
766
|
|
|
765
767
|
### Agent commands
|
|
766
768
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""HTTP client for Dailybot CLI API endpoints."""
|
|
2
2
|
|
|
3
|
+
import functools
|
|
4
|
+
import platform
|
|
3
5
|
import re
|
|
4
6
|
import time
|
|
5
7
|
import unicodedata
|
|
@@ -12,6 +14,7 @@ from urllib.parse import quote, urljoin, urlsplit
|
|
|
12
14
|
|
|
13
15
|
import httpx
|
|
14
16
|
|
|
17
|
+
from dailybot_cli import __version__
|
|
15
18
|
from dailybot_cli.config import (
|
|
16
19
|
API_KEY_SOURCE_ENV_JSON,
|
|
17
20
|
get_agent_name,
|
|
@@ -20,6 +23,7 @@ from dailybot_cli.config import (
|
|
|
20
23
|
get_api_url,
|
|
21
24
|
get_login_token_for,
|
|
22
25
|
)
|
|
26
|
+
from dailybot_cli.install_method import detect_install_method
|
|
23
27
|
|
|
24
28
|
_MAX_LIST_PAGES: int = 50 # safety cap for paginated list endpoints
|
|
25
29
|
LONG_TIMEOUT_SECS: float = 120.0 # AI-processing endpoints (ask, submit_update)
|
|
@@ -33,6 +37,33 @@ ATTACHMENT_TRANSFER_TIMEOUT_SECS: float = LONG_TIMEOUT_SECS
|
|
|
33
37
|
# never cut off by it. This is the wall-clock ceiling for the whole storage hop.
|
|
34
38
|
ATTACHMENT_DOWNLOAD_DEADLINE_SECS: float = 300.0
|
|
35
39
|
|
|
40
|
+
# Every request names the CLI build. The backend measures which CLI versions are
|
|
41
|
+
# still in use from this header before it changes anything an old client relies on.
|
|
42
|
+
USER_AGENT_PRODUCT: str = "dailybot-cli"
|
|
43
|
+
USER_AGENT_UNKNOWN: str = "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@functools.lru_cache(maxsize=1)
|
|
47
|
+
def user_agent() -> str:
|
|
48
|
+
"""`dailybot-cli/<ver> (python/<x.y>; <os>; <install_method>)`, computed once."""
|
|
49
|
+
try:
|
|
50
|
+
method: str = detect_install_method()
|
|
51
|
+
except Exception:
|
|
52
|
+
method = USER_AGENT_UNKNOWN
|
|
53
|
+
python: str = ".".join(platform.python_version_tuple()[:2])
|
|
54
|
+
os_name: str = platform.system().lower() or USER_AGENT_UNKNOWN
|
|
55
|
+
return f"{USER_AGENT_PRODUCT}/{__version__} (python/{python}; {os_name}; {method})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _base_headers() -> dict[str, str]:
|
|
59
|
+
"""The headers every Dailybot API request carries, before any credential."""
|
|
60
|
+
return {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
"User-Agent": user_agent(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
36
67
|
# HTTP status codes that trigger the alt-credential auth retry. 401 is the
|
|
37
68
|
# standards-compliant "credentials rejected" answer; 403 is what many
|
|
38
69
|
# Django/DRF backends actually send for the same condition (including for
|
|
@@ -69,6 +100,9 @@ MAX_PAGE_SIZE: int = 100 # server clamps above this; the client clamps too
|
|
|
69
100
|
|
|
70
101
|
MAX_RATE_LIMIT_RETRIES: int = 3 # attempts to retry a generic 429 before raising
|
|
71
102
|
DEFAULT_RETRY_AFTER_SECS: float = 1.0 # backoff floor when a 429 omits Retry-After
|
|
103
|
+
# The registration challenge GET waits out a short 429 on its own; a longer wait is
|
|
104
|
+
# reported to the user instead of leaving the terminal asleep.
|
|
105
|
+
REGISTRATION_MAX_AUTO_WAIT_SECS: float = 10.0
|
|
72
106
|
# A free-plan daily throttle is NOT transient — retrying can never succeed today.
|
|
73
107
|
FREE_PLAN_DAILY_LIMIT_CODE: str = "free_plan_daily_limit_exceeded"
|
|
74
108
|
|
|
@@ -344,6 +378,7 @@ class APIError(Exception):
|
|
|
344
378
|
code: str | None = None,
|
|
345
379
|
retry_after: float | None = None,
|
|
346
380
|
extra: dict[str, Any] | None = None,
|
|
381
|
+
credential: str | None = None,
|
|
347
382
|
) -> None:
|
|
348
383
|
self.status_code: int = status_code
|
|
349
384
|
self.detail: str = detail
|
|
@@ -352,6 +387,9 @@ class APIError(Exception):
|
|
|
352
387
|
# Extra machine-readable context from the error body (e.g. upgrade_url,
|
|
353
388
|
# required_role / current_role). Never a mutable default arg.
|
|
354
389
|
self.extra: dict[str, Any] = extra or {}
|
|
390
|
+
# Which credential the refused request carried ("api_key" / "bearer"), when
|
|
391
|
+
# known, so a message about an agent key is never shown to a login session.
|
|
392
|
+
self.credential: str | None = credential
|
|
355
393
|
super().__init__(f"API error {status_code}: {detail}")
|
|
356
394
|
|
|
357
395
|
|
|
@@ -476,10 +514,7 @@ class DailyBotClient:
|
|
|
476
514
|
(``self._prefer_api_key``), the order inverts so the per-repo key
|
|
477
515
|
wins on the first attempt; the 401/403 retry covers the reverse.
|
|
478
516
|
"""
|
|
479
|
-
headers: dict[str, str] =
|
|
480
|
-
"Content-Type": "application/json",
|
|
481
|
-
"Accept": "application/json",
|
|
482
|
-
}
|
|
517
|
+
headers: dict[str, str] = _base_headers()
|
|
483
518
|
if authenticated:
|
|
484
519
|
if self.api_key and (self._prefer_api_key or not self.token):
|
|
485
520
|
headers["X-API-KEY"] = self.api_key
|
|
@@ -497,10 +532,7 @@ class DailyBotClient:
|
|
|
497
532
|
that all endpoints behave consistently. The server accepts both on
|
|
498
533
|
every ``/v1/agent*`` endpoint.
|
|
499
534
|
"""
|
|
500
|
-
headers: dict[str, str] =
|
|
501
|
-
"Content-Type": "application/json",
|
|
502
|
-
"Accept": "application/json",
|
|
503
|
-
}
|
|
535
|
+
headers: dict[str, str] = _base_headers()
|
|
504
536
|
if self.api_key and (self._prefer_api_key or not self.token):
|
|
505
537
|
headers["X-API-KEY"] = self.api_key
|
|
506
538
|
self._agent_auth_mode = "api_key"
|
|
@@ -517,10 +549,7 @@ class DailyBotClient:
|
|
|
517
549
|
If the primary was Bearer, tries API key; if the primary was API key,
|
|
518
550
|
tries Bearer. Returns ``None`` when no alternative is available.
|
|
519
551
|
"""
|
|
520
|
-
headers: dict[str, str] =
|
|
521
|
-
"Content-Type": "application/json",
|
|
522
|
-
"Accept": "application/json",
|
|
523
|
-
}
|
|
552
|
+
headers: dict[str, str] = _base_headers()
|
|
524
553
|
if self._agent_auth_mode == "bearer" and self.api_key:
|
|
525
554
|
headers["X-API-KEY"] = self.api_key
|
|
526
555
|
self._agent_auth_mode = "api_key"
|
|
@@ -810,6 +839,7 @@ class DailyBotClient:
|
|
|
810
839
|
code=code,
|
|
811
840
|
retry_after=retry_after,
|
|
812
841
|
extra=extra,
|
|
842
|
+
credential=self._agent_auth_mode,
|
|
813
843
|
)
|
|
814
844
|
if response.status_code == 204:
|
|
815
845
|
return {}
|
|
@@ -823,14 +853,20 @@ class DailyBotClient:
|
|
|
823
853
|
f"The server returned an unreadable response: {_fallback_detail(response)}"
|
|
824
854
|
) from exc
|
|
825
855
|
|
|
826
|
-
def _send_with_retry(
|
|
856
|
+
def _send_with_retry(
|
|
857
|
+
self,
|
|
858
|
+
send: Callable[[], httpx.Response],
|
|
859
|
+
*,
|
|
860
|
+
max_wait_secs: float | None = None,
|
|
861
|
+
) -> httpx.Response:
|
|
827
862
|
"""Issue a request via ``send`` with bounded retry on a transient 429.
|
|
828
863
|
|
|
829
864
|
Honors the ``Retry-After`` header and backs off exponentially from it, up
|
|
830
865
|
to ``MAX_RATE_LIMIT_RETRIES`` retries. A ``free_plan_daily_limit_exceeded``
|
|
831
866
|
429 is NOT transient — it is returned immediately (no retry) so the caller
|
|
832
867
|
raises a clear error. Any non-429 response is returned as-is. ``time.sleep``
|
|
833
|
-
is referenced through the module so tests can patch it.
|
|
868
|
+
is referenced through the module so tests can patch it. With ``max_wait_secs``,
|
|
869
|
+
a wait longer than that is not slept: the 429 is returned for the caller to report.
|
|
834
870
|
"""
|
|
835
871
|
attempt: int = 0
|
|
836
872
|
while True:
|
|
@@ -854,7 +890,10 @@ class DailyBotClient:
|
|
|
854
890
|
retry_after = float(raw_retry)
|
|
855
891
|
except ValueError:
|
|
856
892
|
retry_after = DEFAULT_RETRY_AFTER_SECS
|
|
857
|
-
|
|
893
|
+
wait: float = retry_after * (2**attempt)
|
|
894
|
+
if max_wait_secs is not None and wait > max_wait_secs:
|
|
895
|
+
return response
|
|
896
|
+
time.sleep(wait)
|
|
858
897
|
attempt += 1
|
|
859
898
|
|
|
860
899
|
def _paginated_get(
|
|
@@ -3624,7 +3663,9 @@ class DailyBotClient:
|
|
|
3624
3663
|
method,
|
|
3625
3664
|
url,
|
|
3626
3665
|
content=data,
|
|
3627
|
-
headers
|
|
3666
|
+
# Only the presign's own headers (they win: a signed upload may pin
|
|
3667
|
+
# them) plus the User-Agent. Never a Dailybot credential.
|
|
3668
|
+
headers={"User-Agent": user_agent(), **headers},
|
|
3628
3669
|
timeout=ATTACHMENT_TRANSFER_TIMEOUT_SECS,
|
|
3629
3670
|
follow_redirects=False,
|
|
3630
3671
|
)
|
|
@@ -4145,7 +4186,7 @@ class DailyBotClient:
|
|
|
4145
4186
|
with httpx.stream(
|
|
4146
4187
|
"GET",
|
|
4147
4188
|
location,
|
|
4148
|
-
headers={"Accept-Encoding": "identity"},
|
|
4189
|
+
headers={"Accept-Encoding": "identity", "User-Agent": user_agent()},
|
|
4149
4190
|
timeout=ATTACHMENT_TRANSFER_TIMEOUT_SECS,
|
|
4150
4191
|
follow_redirects=False,
|
|
4151
4192
|
) as response:
|
|
@@ -4839,14 +4880,21 @@ class DailyBotClient:
|
|
|
4839
4880
|
# --- Agent registration endpoints ---
|
|
4840
4881
|
|
|
4841
4882
|
def get_registration_challenge(self) -> dict[str, Any]:
|
|
4842
|
-
"""GET /v1/agent/register/challenge/ — no auth required.
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4883
|
+
"""GET /v1/agent/register/challenge/ — no auth required.
|
|
4884
|
+
|
|
4885
|
+
A read, so a short 429 is waited out (``Retry-After``). The POST that follows
|
|
4886
|
+
is never retried this way: every attempt consumes its challenge.
|
|
4887
|
+
"""
|
|
4888
|
+
response: httpx.Response = self._send_with_retry(
|
|
4889
|
+
lambda: self._guard_transport(
|
|
4890
|
+
lambda: httpx.get(
|
|
4891
|
+
f"{self.api_url}/v1/agent/register/challenge/",
|
|
4892
|
+
headers=self._headers(authenticated=False),
|
|
4893
|
+
timeout=self.timeout,
|
|
4894
|
+
),
|
|
4895
|
+
method="GET",
|
|
4848
4896
|
),
|
|
4849
|
-
|
|
4897
|
+
max_wait_secs=REGISTRATION_MAX_AUTO_WAIT_SECS,
|
|
4850
4898
|
)
|
|
4851
4899
|
return self._handle_response(response)
|
|
4852
4900
|
|
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
|
|
10
10
|
from dailybot_cli import ledger
|
|
11
11
|
from dailybot_cli.api_client import APIError, DailyBotClient
|
|
12
|
+
from dailybot_cli.commands.public_api_helpers import agent_error_message
|
|
12
13
|
from dailybot_cli.config import (
|
|
13
14
|
RepoEnvError,
|
|
14
15
|
RepoProfileError,
|
|
@@ -80,9 +81,30 @@ def _reset_init_nudge() -> None:
|
|
|
80
81
|
_NUDGE_SHOWN = False
|
|
81
82
|
|
|
82
83
|
|
|
83
|
-
#
|
|
84
|
-
|
|
84
|
+
# Registration challenge (API contract rev 1). It is a compatibility speed bump, not a
|
|
85
|
+
# security control: the answer is the session code times the number of words in the
|
|
86
|
+
# instruction. The CLI counts the words so the server can change the wording; 52 is
|
|
87
|
+
# today's count, kept only as the fallback for an instruction too short to be real.
|
|
88
|
+
_CHALLENGE_LEGACY_WORD_COUNT: int = 52
|
|
89
|
+
_CHALLENGE_MIN_WORDS: int = 5
|
|
85
90
|
_CHALLENGE_NUMBER_RE: re.Pattern[str] = re.compile(r"session is (\d+)\.")
|
|
91
|
+
_CHALLENGE_UNREADABLE_MESSAGE: str = (
|
|
92
|
+
"Could not read the registration challenge from the server. This CLI may be too old "
|
|
93
|
+
"for it: run `dailybot upgrade`, then try again."
|
|
94
|
+
)
|
|
95
|
+
_CHALLENGE_EXPIRED_CODE: str = "challenge_expired"
|
|
96
|
+
_SCOPE_REFUSAL_CODE: str = "plan_upgrade_required"
|
|
97
|
+
_RATE_LIMITED_MESSAGE: str = "Rate limited."
|
|
98
|
+
_RATE_LIMITED_DEFAULT_HINT: str = "Try again in a few minutes."
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _key_is_refused(exc: APIError) -> bool:
|
|
102
|
+
"""True when validating a key proves it is unusable.
|
|
103
|
+
|
|
104
|
+
A 403 ``plan_upgrade_required`` means the key authenticated but the call is out of
|
|
105
|
+
its scope (a self-registered key is least-privilege), so the key itself is valid.
|
|
106
|
+
"""
|
|
107
|
+
return exc.status_code in (401, 403) and exc.code != _SCOPE_REFUSAL_CODE
|
|
86
108
|
|
|
87
109
|
|
|
88
110
|
def _resolve_agent_context(
|
|
@@ -301,7 +323,7 @@ def agent_configure(
|
|
|
301
323
|
with console.status("Validating API key..."):
|
|
302
324
|
client.get_agent_health(agent_name=name)
|
|
303
325
|
except APIError as e:
|
|
304
|
-
if e
|
|
326
|
+
if _key_is_refused(e):
|
|
305
327
|
print_error("API key is invalid or unauthorized.")
|
|
306
328
|
raise SystemExit(1)
|
|
307
329
|
# Non-auth error means the key itself is valid
|
|
@@ -464,7 +486,7 @@ def _init_personal(questionary: Any) -> None:
|
|
|
464
486
|
with console.status("Validating API key..."):
|
|
465
487
|
client.get_agent_health(agent_name=name)
|
|
466
488
|
except APIError as exc:
|
|
467
|
-
if exc
|
|
489
|
+
if _key_is_refused(exc):
|
|
468
490
|
print_error("API key is invalid or unauthorized. Nothing was saved.")
|
|
469
491
|
raise SystemExit(1)
|
|
470
492
|
# Non-auth error → key is valid; the failure is unrelated.
|
|
@@ -660,7 +682,7 @@ def agent_update(
|
|
|
660
682
|
print_pending_agent_messages(pending)
|
|
661
683
|
_maybe_show_init_nudge(agent_name)
|
|
662
684
|
except APIError as e:
|
|
663
|
-
print_error(e
|
|
685
|
+
print_error(agent_error_message(e))
|
|
664
686
|
raise SystemExit(1)
|
|
665
687
|
|
|
666
688
|
|
|
@@ -715,7 +737,7 @@ def agent_health(
|
|
|
715
737
|
)
|
|
716
738
|
print_agent_health(result)
|
|
717
739
|
except APIError as e:
|
|
718
|
-
print_error(e
|
|
740
|
+
print_error(agent_error_message(e))
|
|
719
741
|
raise SystemExit(1)
|
|
720
742
|
|
|
721
743
|
|
|
@@ -755,7 +777,7 @@ def webhook_register(
|
|
|
755
777
|
)
|
|
756
778
|
print_webhook_result(result)
|
|
757
779
|
except APIError as e:
|
|
758
|
-
print_error(e
|
|
780
|
+
print_error(agent_error_message(e))
|
|
759
781
|
raise SystemExit(1)
|
|
760
782
|
|
|
761
783
|
|
|
@@ -778,7 +800,7 @@ def webhook_unregister(ctx: click.Context, name: str | None, profile: str | None
|
|
|
778
800
|
result: dict[str, Any] = client.unregister_agent_webhook(agent_name=agent_name)
|
|
779
801
|
print_success(result.get("detail", "Webhook unregistered."))
|
|
780
802
|
except APIError as e:
|
|
781
|
-
print_error(e
|
|
803
|
+
print_error(agent_error_message(e))
|
|
782
804
|
raise SystemExit(1)
|
|
783
805
|
|
|
784
806
|
|
|
@@ -844,7 +866,7 @@ def message_send(
|
|
|
844
866
|
)
|
|
845
867
|
print_agent_message_sent(result)
|
|
846
868
|
except APIError as e:
|
|
847
|
-
print_error(e
|
|
869
|
+
print_error(agent_error_message(e))
|
|
848
870
|
raise SystemExit(1)
|
|
849
871
|
|
|
850
872
|
|
|
@@ -872,7 +894,7 @@ def message_list(ctx: click.Context, name: str | None, profile: str | None, pend
|
|
|
872
894
|
)
|
|
873
895
|
print_agent_messages(messages)
|
|
874
896
|
except APIError as e:
|
|
875
|
-
print_error(e
|
|
897
|
+
print_error(agent_error_message(e))
|
|
876
898
|
raise SystemExit(1)
|
|
877
899
|
|
|
878
900
|
|
|
@@ -898,7 +920,7 @@ def message_claim(ctx: click.Context, message_ids: tuple[str, ...], profile: str
|
|
|
898
920
|
count: int = result.get("updated", 0)
|
|
899
921
|
print_success(f"Marked {count} message(s) as read.")
|
|
900
922
|
except APIError as e:
|
|
901
|
-
print_error(e
|
|
923
|
+
print_error(agent_error_message(e))
|
|
902
924
|
raise SystemExit(1)
|
|
903
925
|
|
|
904
926
|
|
|
@@ -925,7 +947,7 @@ def message_claim_all(ctx: click.Context, name: str | None, profile: str | None)
|
|
|
925
947
|
)
|
|
926
948
|
print_success("All pending messages marked as delivered.")
|
|
927
949
|
except APIError as e:
|
|
928
|
-
print_error(e
|
|
950
|
+
print_error(agent_error_message(e))
|
|
929
951
|
raise SystemExit(1)
|
|
930
952
|
|
|
931
953
|
|
|
@@ -993,11 +1015,11 @@ def email_send(
|
|
|
993
1015
|
)
|
|
994
1016
|
print_agent_email_sent(result)
|
|
995
1017
|
except APIError as e:
|
|
996
|
-
if e.status_code == 429:
|
|
1018
|
+
if e.status_code == 429 and not e.code:
|
|
997
1019
|
print_error("Hourly email limit exceeded. Try again later.")
|
|
998
1020
|
print_info(e.detail)
|
|
999
1021
|
else:
|
|
1000
|
-
print_error(e
|
|
1022
|
+
print_error(agent_error_message(e))
|
|
1001
1023
|
raise SystemExit(1)
|
|
1002
1024
|
|
|
1003
1025
|
|
|
@@ -1005,13 +1027,38 @@ def email_send(
|
|
|
1005
1027
|
|
|
1006
1028
|
|
|
1007
1029
|
def _solve_challenge(instruction: str) -> int:
|
|
1008
|
-
"""
|
|
1030
|
+
"""Answer the challenge: the session code times the instruction's word count."""
|
|
1009
1031
|
match: re.Match[str] | None = _CHALLENGE_NUMBER_RE.search(instruction)
|
|
1010
1032
|
if not match:
|
|
1011
|
-
print_error(
|
|
1033
|
+
print_error(_CHALLENGE_UNREADABLE_MESSAGE)
|
|
1012
1034
|
raise SystemExit(1)
|
|
1013
1035
|
random_number: int = int(match.group(1))
|
|
1014
|
-
|
|
1036
|
+
word_count: int = len(instruction.split())
|
|
1037
|
+
if word_count < _CHALLENGE_MIN_WORDS:
|
|
1038
|
+
word_count = _CHALLENGE_LEGACY_WORD_COUNT
|
|
1039
|
+
return random_number * word_count
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _is_challenge_expired(exc: APIError) -> bool:
|
|
1043
|
+
"""True for an expired challenge. A server older than contract rev 1 sends no code."""
|
|
1044
|
+
if exc.code:
|
|
1045
|
+
return exc.code == _CHALLENGE_EXPIRED_CODE
|
|
1046
|
+
detail: str = exc.detail.lower()
|
|
1047
|
+
return "challenge" in detail and "expired" in detail
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _registration_error_message(exc: APIError) -> str:
|
|
1051
|
+
"""What a failed registration tells the user: the code's message, else the detail."""
|
|
1052
|
+
if exc.code:
|
|
1053
|
+
return agent_error_message(exc)
|
|
1054
|
+
if exc.status_code == 429:
|
|
1055
|
+
hint: str = (
|
|
1056
|
+
f"Try again in {int(exc.retry_after)}s."
|
|
1057
|
+
if exc.retry_after
|
|
1058
|
+
else _RATE_LIMITED_DEFAULT_HINT
|
|
1059
|
+
)
|
|
1060
|
+
return f"{_RATE_LIMITED_MESSAGE} {hint}"
|
|
1061
|
+
return exc.detail
|
|
1015
1062
|
|
|
1016
1063
|
|
|
1017
1064
|
@agent.command(name="register")
|
|
@@ -1058,21 +1105,16 @@ def agent_register(
|
|
|
1058
1105
|
)
|
|
1059
1106
|
|
|
1060
1107
|
try:
|
|
1061
|
-
|
|
1108
|
+
try:
|
|
1109
|
+
result: dict[str, Any] = _attempt_register()
|
|
1110
|
+
except APIError as e:
|
|
1111
|
+
if not _is_challenge_expired(e):
|
|
1112
|
+
raise
|
|
1113
|
+
# Every attempt consumes its challenge: retry once with a fresh one.
|
|
1114
|
+
result = _attempt_register()
|
|
1062
1115
|
except APIError as e:
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
try:
|
|
1066
|
-
result = _attempt_register()
|
|
1067
|
-
except APIError as retry_e:
|
|
1068
|
-
print_error(retry_e.detail)
|
|
1069
|
-
raise SystemExit(1)
|
|
1070
|
-
elif e.status_code == 429:
|
|
1071
|
-
print_error("Rate limited. Try again in a few minutes.")
|
|
1072
|
-
raise SystemExit(1)
|
|
1073
|
-
else:
|
|
1074
|
-
print_error(e.detail)
|
|
1075
|
-
raise SystemExit(1)
|
|
1116
|
+
print_error(_registration_error_message(e))
|
|
1117
|
+
raise SystemExit(1)
|
|
1076
1118
|
|
|
1077
1119
|
# Save profile
|
|
1078
1120
|
api_key: str | None = result.get("api_key")
|
|
@@ -1082,8 +1124,3 @@ def agent_register(
|
|
|
1082
1124
|
# Display result
|
|
1083
1125
|
result["profile"] = slug
|
|
1084
1126
|
print_registration_result(result)
|
|
1085
|
-
claim_url: str = result.get("claim_url", "")
|
|
1086
|
-
if claim_url:
|
|
1087
|
-
print_info(
|
|
1088
|
-
"Share the claim URL with your team admin to connect this org to Slack or Google Chat. The URL expires in 30 days."
|
|
1089
|
-
)
|
|
@@ -961,6 +961,78 @@ def exit_for_tasks_error(
|
|
|
961
961
|
raise SystemExit(code)
|
|
962
962
|
|
|
963
963
|
|
|
964
|
+
# Agent self-registration and claim (API contract rev 1). Scoped to agent commands
|
|
965
|
+
# (looked up only by `agent_error_message`), because generic names such as
|
|
966
|
+
# `missing_fields` or `invalid_answer` could mean something else on another surface.
|
|
967
|
+
# `challenge_expired` is retried once by `agent register` before it reaches the user.
|
|
968
|
+
REGISTRATION_CODE_MESSAGES: dict[str, str] = {
|
|
969
|
+
"missing_fields": "The registration request was missing required fields. Run `dailybot upgrade`, then try again.",
|
|
970
|
+
"invalid_answer": (
|
|
971
|
+
"The registration challenge answer was not a number. This CLI may be too old for "
|
|
972
|
+
"the server: run `dailybot upgrade`, then try again."
|
|
973
|
+
),
|
|
974
|
+
"challenge_invalid": "The registration challenge was not recognized. Run the command again to get a new one.",
|
|
975
|
+
"challenge_used": (
|
|
976
|
+
"That registration challenge was already used (every attempt consumes it). Run the "
|
|
977
|
+
"command again to get a new one."
|
|
978
|
+
),
|
|
979
|
+
"challenge_expired": "The registration challenge expired. Run the command again to get a new one.",
|
|
980
|
+
"answer_incorrect": (
|
|
981
|
+
"The registration challenge answer was rejected. This CLI may be too old for the "
|
|
982
|
+
"server: run `dailybot upgrade`, then try again."
|
|
983
|
+
),
|
|
984
|
+
"reason_too_short": "The registration reason is too short (at least 20 characters).",
|
|
985
|
+
"reason_echoes_instruction": (
|
|
986
|
+
"The registration reason repeats the challenge instruction. Describe why the agent is registering."
|
|
987
|
+
),
|
|
988
|
+
"registration_rate_limited": "Rate limited: too many registration attempts.",
|
|
989
|
+
"claim_token_unavailable": (
|
|
990
|
+
"No claim link is available: the organization was already claimed or the link "
|
|
991
|
+
"expired. A new link cannot be issued yet."
|
|
992
|
+
),
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
# Agent commands run on agent keys, where these two codes mean something narrower than
|
|
997
|
+
# on a person's session (API contract rev 1: a self-registered key is least-privilege
|
|
998
|
+
# on every plan, and an org admin can delete the agent behind it).
|
|
999
|
+
_INVALID_CREDENTIALS_CODE: str = "invalid_credentials"
|
|
1000
|
+
_API_KEY_CREDENTIAL: str = "api_key"
|
|
1001
|
+
AGENT_CODE_MESSAGES: dict[str, str] = {
|
|
1002
|
+
"plan_upgrade_required": (
|
|
1003
|
+
"This credential is not allowed to do that. On the free plan, and with a "
|
|
1004
|
+
"self-registered agent key on any plan, agent commands are limited to agent "
|
|
1005
|
+
"reports, health and messages; `agent email` and `agent webhook` are not included."
|
|
1006
|
+
),
|
|
1007
|
+
"invalid_credentials": (
|
|
1008
|
+
"This agent key was rejected. An org admin may have deleted the agent, or the "
|
|
1009
|
+
"key belongs to another server (`dailybot env show`). Rotate the key or run "
|
|
1010
|
+
"`dailybot login`."
|
|
1011
|
+
),
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def agent_error_message(exc: APIError) -> str:
|
|
1016
|
+
"""The user-facing message for an agent command's ``APIError``.
|
|
1017
|
+
|
|
1018
|
+
Dispatches on ``code`` (agent-specific copy first, then registration, then the
|
|
1019
|
+
shared map); an unknown or absent code falls back to the server's ``detail``. The
|
|
1020
|
+
agent-key copy for ``invalid_credentials`` is used only when the refused request
|
|
1021
|
+
carried an API key, never for a login session. A 429 gets the wait hint.
|
|
1022
|
+
"""
|
|
1023
|
+
code: str | None = exc.code
|
|
1024
|
+
base: str | None = None
|
|
1025
|
+
if code:
|
|
1026
|
+
agent_copy: str | None = AGENT_CODE_MESSAGES.get(code)
|
|
1027
|
+
if code == _INVALID_CREDENTIALS_CODE and exc.credential != _API_KEY_CREDENTIAL:
|
|
1028
|
+
agent_copy = None
|
|
1029
|
+
base = agent_copy or REGISTRATION_CODE_MESSAGES.get(code) or ERROR_CODE_MESSAGES.get(code)
|
|
1030
|
+
message: str = (
|
|
1031
|
+
_augment_code_message(base, code, exc.extra or {}) if base and code else exc.detail
|
|
1032
|
+
)
|
|
1033
|
+
return f"{message}{_throttle_hint(exc, _retry_after_seconds(exc))}"
|
|
1034
|
+
|
|
1035
|
+
|
|
964
1036
|
def exit_for_api_error(
|
|
965
1037
|
exc: APIError,
|
|
966
1038
|
json_mode: bool,
|
|
@@ -48,6 +48,8 @@ CREDENTIALS_FILE: Path = CONFIG_DIR / "credentials.json"
|
|
|
48
48
|
CONFIG_FILE: Path = CONFIG_DIR / "config.json"
|
|
49
49
|
ORG_CACHE_FILE: Path = CONFIG_DIR / "org_cache.json"
|
|
50
50
|
AGENTS_FILE: Path = CONFIG_DIR / "agents.json"
|
|
51
|
+
SECRET_FILE_MODE: int = 0o600
|
|
52
|
+
CONFIG_DIR_MODE: int = 0o700
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
def get_config_dir() -> Path:
|
|
@@ -58,10 +60,37 @@ def get_config_dir() -> Path:
|
|
|
58
60
|
"""
|
|
59
61
|
env_override: str | None = os.environ.get("DAILYBOT_CONFIG_DIR")
|
|
60
62
|
path: Path = Path(env_override) if env_override else CONFIG_DIR
|
|
61
|
-
|
|
63
|
+
# The mode applies only when the directory is created: an existing one is never
|
|
64
|
+
# loosened (or tightened behind the user's back).
|
|
65
|
+
path.mkdir(mode=CONFIG_DIR_MODE, parents=True, exist_ok=True)
|
|
62
66
|
return path
|
|
63
67
|
|
|
64
68
|
|
|
69
|
+
def _tighten_before_write(fd: int) -> None:
|
|
70
|
+
"""Set 0o600 on an open descriptor before any secret byte is written.
|
|
71
|
+
|
|
72
|
+
``os.fchmod`` is POSIX-only (Windows gains it in Python 3.13); there the caller
|
|
73
|
+
falls back to ``os.chmod`` after the write, where POSIX modes are advisory anyway.
|
|
74
|
+
"""
|
|
75
|
+
if hasattr(os, "fchmod"):
|
|
76
|
+
os.fchmod(fd, SECRET_FILE_MODE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_secret_file(path: Path, text: str) -> None:
|
|
80
|
+
"""Write a secret store with mode 0o600 from the first byte.
|
|
81
|
+
|
|
82
|
+
A write-then-chmod leaves a umask-permission window while the file already holds
|
|
83
|
+
a token or key. A pre-existing file keeps its old mode through os.open, so the
|
|
84
|
+
descriptor is tightened before the first byte is written.
|
|
85
|
+
"""
|
|
86
|
+
fd: int = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE)
|
|
87
|
+
_tighten_before_write(fd)
|
|
88
|
+
with os.fdopen(fd, "w") as handle:
|
|
89
|
+
handle.write(text)
|
|
90
|
+
if not hasattr(os, "fchmod"):
|
|
91
|
+
os.chmod(path, SECRET_FILE_MODE)
|
|
92
|
+
|
|
93
|
+
|
|
65
94
|
def _credentials_path() -> Path:
|
|
66
95
|
return get_config_dir() / "credentials.json"
|
|
67
96
|
|
|
@@ -100,7 +129,8 @@ def save_credentials(
|
|
|
100
129
|
"""Save credentials to disk."""
|
|
101
130
|
creds_path: Path = _credentials_path()
|
|
102
131
|
get_config_dir()
|
|
103
|
-
|
|
132
|
+
_write_secret_file(
|
|
133
|
+
creds_path,
|
|
104
134
|
json.dumps(
|
|
105
135
|
{
|
|
106
136
|
"token": token,
|
|
@@ -110,10 +140,8 @@ def save_credentials(
|
|
|
110
140
|
"api_url": api_url,
|
|
111
141
|
},
|
|
112
142
|
indent=2,
|
|
113
|
-
)
|
|
143
|
+
),
|
|
114
144
|
)
|
|
115
|
-
# Restrict file permissions (owner read/write only)
|
|
116
|
-
os.chmod(creds_path, 0o600)
|
|
117
145
|
|
|
118
146
|
|
|
119
147
|
def clear_credentials() -> None:
|
|
@@ -255,8 +283,7 @@ def save_config(data: dict[str, Any]) -> None:
|
|
|
255
283
|
existing[key] = value
|
|
256
284
|
get_config_dir()
|
|
257
285
|
config_path: Path = _config_path()
|
|
258
|
-
config_path
|
|
259
|
-
os.chmod(config_path, 0o600)
|
|
286
|
+
_write_secret_file(config_path, json.dumps(existing, indent=2))
|
|
260
287
|
|
|
261
288
|
|
|
262
289
|
def get_api_key() -> str | None:
|
|
@@ -325,7 +352,7 @@ def save_org_cache(email: str, organizations: list[dict[str, Any]]) -> None:
|
|
|
325
352
|
"""Cache the org list from request_code for UUID resolution in step 2."""
|
|
326
353
|
get_config_dir()
|
|
327
354
|
data: dict[str, Any] = {"email": email, "organizations": organizations}
|
|
328
|
-
_org_cache_path()
|
|
355
|
+
_write_secret_file(_org_cache_path(), json.dumps(data))
|
|
329
356
|
|
|
330
357
|
|
|
331
358
|
def load_org_cache(email: str) -> list[dict[str, Any]] | None:
|
|
@@ -375,8 +402,7 @@ def save_org_plan(organization_uuid: str, plan: str | None) -> None:
|
|
|
375
402
|
data.pop(organization_uuid, None)
|
|
376
403
|
else:
|
|
377
404
|
data[organization_uuid] = plan
|
|
378
|
-
cache_path
|
|
379
|
-
os.chmod(cache_path, 0o600)
|
|
405
|
+
_write_secret_file(cache_path, json.dumps(data, indent=2))
|
|
380
406
|
|
|
381
407
|
|
|
382
408
|
def get_org_plan(organization_uuid: str | None) -> str | None:
|
|
@@ -440,8 +466,7 @@ def _save_agents(data: dict[str, Any]) -> None:
|
|
|
440
466
|
"""Write agents.json with restricted permissions."""
|
|
441
467
|
get_config_dir()
|
|
442
468
|
agents_path: Path = _agents_path()
|
|
443
|
-
agents_path
|
|
444
|
-
os.chmod(agents_path, 0o600)
|
|
469
|
+
_write_secret_file(agents_path, json.dumps(data, indent=2))
|
|
445
470
|
|
|
446
471
|
|
|
447
472
|
def save_agent_profile(
|
|
@@ -1178,11 +1203,14 @@ def save_repo_env(payload: dict[str, Any], *, cwd: Path | None = None) -> Path:
|
|
|
1178
1203
|
serialized: str = json.dumps(_normalize_env_payload(payload), indent=2) + "\n"
|
|
1179
1204
|
# Create with 0o600 from the very first byte — a plain write-then-chmod
|
|
1180
1205
|
# leaves a umask-permission window while the file already holds API keys.
|
|
1181
|
-
|
|
1206
|
+
# A pre-existing file keeps its old mode through os.open — tighten the descriptor
|
|
1207
|
+
# before writing.
|
|
1208
|
+
fd: int = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE)
|
|
1209
|
+
_tighten_before_write(fd)
|
|
1182
1210
|
with os.fdopen(fd, "w") as handle:
|
|
1183
1211
|
handle.write(serialized)
|
|
1184
|
-
|
|
1185
|
-
|
|
1212
|
+
if not hasattr(os, "fchmod"):
|
|
1213
|
+
os.chmod(env_path, SECRET_FILE_MODE)
|
|
1186
1214
|
return env_path
|
|
1187
1215
|
|
|
1188
1216
|
|
|
@@ -6,6 +6,7 @@ tag, so every such message is escaped before interpolation.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import unicodedata
|
|
9
|
+
from datetime import datetime, timezone
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
import click
|
|
@@ -766,24 +767,64 @@ def print_env_profiles_table(
|
|
|
766
767
|
console.print(table)
|
|
767
768
|
|
|
768
769
|
|
|
770
|
+
CLAIM_EXPIRY_FALLBACK: str = "See the claim page for when it expires."
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def format_claim_expiry(value: Any) -> str:
|
|
774
|
+
"""`claim_expires_at` (ISO 8601 UTC) as `YYYY-MM-DD HH:MM UTC`; odd values as sent."""
|
|
775
|
+
if not isinstance(value, str) or not value.strip():
|
|
776
|
+
return ""
|
|
777
|
+
try:
|
|
778
|
+
parsed: datetime = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
779
|
+
except ValueError:
|
|
780
|
+
return value.strip()
|
|
781
|
+
# The contract sends UTC; a value without an offset is read as UTC, not local time.
|
|
782
|
+
parsed = (
|
|
783
|
+
parsed.replace(tzinfo=timezone.utc)
|
|
784
|
+
if parsed.tzinfo is None
|
|
785
|
+
else parsed.astimezone(timezone.utc)
|
|
786
|
+
)
|
|
787
|
+
return parsed.strftime("%Y-%m-%d %H:%M UTC")
|
|
788
|
+
|
|
789
|
+
|
|
769
790
|
def print_registration_result(data: dict[str, Any]) -> None:
|
|
770
|
-
"""Display agent registration
|
|
791
|
+
"""Display an agent registration: the panel, then what to send the human owner.
|
|
792
|
+
|
|
793
|
+
The API key is saved to the profile by the caller and never shown here.
|
|
794
|
+
"""
|
|
771
795
|
table: Table = Table(show_header=False, box=None, padding=(0, 2))
|
|
772
796
|
table.add_column(style="bold")
|
|
773
797
|
table.add_column(no_wrap=True, overflow="fold")
|
|
774
|
-
table.add_row("Agent", data.get("agent_name", ""))
|
|
775
|
-
agent_email: str = data.get("agent_email"
|
|
798
|
+
table.add_row("Agent", escape(str(data.get("agent_name", ""))))
|
|
799
|
+
agent_email: str = str(data.get("agent_email") or "")
|
|
776
800
|
if agent_email:
|
|
777
|
-
table.add_row("Email", f"[bold]{agent_email}[/bold]")
|
|
778
|
-
table.add_row("Org", data.get("org_name", ""))
|
|
801
|
+
table.add_row("Email", f"[bold]{escape(agent_email)}[/bold]")
|
|
802
|
+
table.add_row("Org", escape(str(data.get("org_name", ""))))
|
|
779
803
|
if data.get("profile"):
|
|
780
|
-
table.add_row("Profile", data["profile"])
|
|
781
|
-
claim_url: str = data.get("claim_url"
|
|
804
|
+
table.add_row("Profile", escape(str(data["profile"])))
|
|
805
|
+
claim_url: str = str(data.get("claim_url") or "")
|
|
806
|
+
expiry: str = format_claim_expiry(data.get("claim_expires_at"))
|
|
782
807
|
if claim_url:
|
|
783
|
-
table.add_row("Claim URL", f"[bold cyan]{claim_url}[/bold cyan]")
|
|
808
|
+
table.add_row("Claim URL", f"[bold cyan]{escape(claim_url)}[/bold cyan]")
|
|
809
|
+
if expiry:
|
|
810
|
+
table.add_row("Claim expires", escape(expiry))
|
|
784
811
|
console.print(Panel(table, title="[bold]Agent Registered[/bold]", border_style="green"))
|
|
785
|
-
if claim_url:
|
|
786
|
-
|
|
812
|
+
if not claim_url:
|
|
813
|
+
return
|
|
814
|
+
expires_line: str = f"The link expires on {expiry}." if expiry else CLAIM_EXPIRY_FALLBACK
|
|
815
|
+
console.print(
|
|
816
|
+
"\n[bold]Send this to your human[/bold] (the person who will own this organization):\n"
|
|
817
|
+
)
|
|
818
|
+
console.print(f"Claim URL: [bold cyan]{escape(claim_url)}[/bold cyan]")
|
|
819
|
+
console.print(
|
|
820
|
+
"Open it while signed in to Dailybot on the web to become an admin of this "
|
|
821
|
+
"organization, then connect it to Slack, Microsoft Teams, Discord or Google Chat. "
|
|
822
|
+
f"{escape(expires_line)}"
|
|
823
|
+
)
|
|
824
|
+
console.print(
|
|
825
|
+
"[dim]Treat the link like a password: anyone who opens it signed in can claim "
|
|
826
|
+
"the organization.[/dim]"
|
|
827
|
+
)
|
|
787
828
|
|
|
788
829
|
|
|
789
830
|
def print_update_result(data: dict[str, Any]) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dailybot-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.28.1
|
|
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
|
|
@@ -799,7 +799,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
|
|
|
799
799
|
|
|
800
800
|
This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
|
|
801
801
|
|
|
802
|
-
The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The
|
|
802
|
+
The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
|
|
803
|
+
|
|
804
|
+
The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
|
|
803
805
|
|
|
804
806
|
### Agent commands
|
|
805
807
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dailybot-cli"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.28.1"
|
|
8
8
|
description = "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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|