From 6c1bf4d7c37b7c15fa0c1a660796e01c9597701e Mon Sep 17 00:00:00 2001 From: AndresMpa Date: Thu, 2 Jul 2026 01:24:12 +0000 Subject: [PATCH 01/12] feat(cli): expand interactive terminal flows Add native interactive menus for Dailybot actions and autocomplete support so terminal users can complete chat-style workflows without Slack buttons. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 154 ++- dailybot_cli/tui/app.py | 1853 +++++++++++++++++++++++++++++++++- dailybot_cli/tui/commands.py | 71 +- dailybot_cli/tui/intents.py | 205 ++++ tests/api_client_test.py | 138 ++- tests/tui_app_flows_test.py | 359 +++++++ tests/tui_intents_test.py | 75 ++ 7 files changed, 2830 insertions(+), 25 deletions(-) create mode 100644 dailybot_cli/tui/intents.py create mode 100644 tests/tui_app_flows_test.py create mode 100644 tests/tui_intents_test.py diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 736d420..fedf9ac 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -220,6 +220,150 @@ def complete_checkin( ) return self._handle_response(response) + def list_checkins(self) -> list[dict[str, Any]]: + """GET /v1/checkins/ — fetch visible check-ins.""" + results: list[dict[str, Any]] = [] + url: str | None = f"{self.api_url}/v1/checkins/" + pages_fetched: int = 0 + while url is not None and pages_fetched < _MAX_LIST_PAGES: + response: httpx.Response = httpx.get( + url, + headers=self._headers(), + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict) and "results" in body: + results.extend(body.get("results", [])) + url = body.get("next") + elif isinstance(body, list): + results.extend(body) + url = None + else: + url = None + pages_fetched += 1 + return results + + def get_checkin(self, followup_uuid: str) -> dict[str, Any]: + """GET /v1/checkins//.""" + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/checkins/{followup_uuid}/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def get_template( + self, + template_uuid: str, + *, + followup_uuid: str | None = None, + ) -> dict[str, Any]: + """GET /v1/templates// — template question definitions.""" + params: dict[str, str] = {} + if followup_uuid: + params = {"render_special_vars": "true", "followup_id": followup_uuid} + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/templates/{template_uuid}/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + return self._handle_response(response) + + def list_checkin_responses( + self, + followup_uuid: str, + *, + date_start: str | None = None, + date_end: str | None = None, + ) -> list[dict[str, Any]]: + """GET /v1/checkins//responses/.""" + params: dict[str, str] = {} + if date_start: + params["date_start"] = date_start + if date_end: + params["date_end"] = date_end + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/checkins/{followup_uuid}/responses/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict) and "results" in body: + return list(body.get("results", [])) + if isinstance(body, list): + return body + return [] + + def update_checkin_response( + self, + followup_uuid: str, + responses: list[dict[str, Any]], + last_question_index: int | None = None, + ) -> dict[str, Any]: + """PUT /v1/checkins//responses/ — update today's response.""" + payload: dict[str, Any] = {"responses": responses} + if last_question_index is not None: + payload["last_question_index"] = last_question_index + response: httpx.Response = httpx.put( + f"{self.api_url}/v1/checkins/{followup_uuid}/responses/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def delete_checkin_response( + self, + followup_uuid: str, + *, + response_date: str | None = None, + ) -> dict[str, Any]: + """DELETE /v1/checkins//responses/ — reset a submitted response.""" + params: dict[str, str] = {} + if response_date: + params["date_start"] = response_date + params["date_end"] = response_date + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/checkins/{followup_uuid}/responses/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + return self._handle_response(response) + + def get_mood(self, mood_date: str | None = None) -> dict[str, Any]: + """GET /v1/mood/track/ — fetch today's mood response.""" + params: dict[str, str] = {} + if mood_date: + params["date"] = mood_date + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/mood/track/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + return self._handle_response(response) + + def track_mood(self, score: int, mood_date: str | None = None) -> dict[str, Any]: + """POST /v1/mood/track/ — record a mood score.""" + payload: dict[str, Any] = {"score": score} + if mood_date: + payload["date"] = mood_date + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/mood/track/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def list_forms(self, *, include_questions: bool = False) -> list[dict[str, Any]]: """GET /v1/forms/ — optionally expand question definitions per form.""" params: dict[str, str] = {"include": "questions"} if include_questions else {} @@ -378,13 +522,17 @@ def give_kudos( """POST /v1/kudos/ At least one of ``user_uuid_receivers`` or ``team_uuid_receivers`` must - be non-empty — the backend rejects an empty receiver set. + be non-empty. The public API requires the legacy ``receivers`` list for + validation and the more specific receiver lists for team expansion. """ payload: dict[str, Any] = {"content": content} + receivers: list[str] = [*(user_uuid_receivers or []), *(team_uuid_receivers or [])] + if receivers: + payload["receivers"] = receivers if user_uuid_receivers: - payload["user_uuid_receivers"] = user_uuid_receivers + payload["users_receivers"] = user_uuid_receivers if team_uuid_receivers: - payload["team_uuid_receivers"] = team_uuid_receivers + payload["teams_receivers"] = team_uuid_receivers if company_value: payload["company_value"] = company_value response: httpx.Response = httpx.post( diff --git a/dailybot_cli/tui/app.py b/dailybot_cli/tui/app.py index d8ba868..f73b3d4 100644 --- a/dailybot_cli/tui/app.py +++ b/dailybot_cli/tui/app.py @@ -1,6 +1,7 @@ """Textual application for `dailybot interactive`.""" import asyncio +from datetime import date from typing import Any, ClassVar import httpx @@ -18,15 +19,38 @@ from dailybot_cli.tui.commands import ( COMMAND_CHECKINS, COMMAND_CLEAR, + COMMAND_COMPLETIONS, + COMMAND_DASHBOARD, + COMMAND_FORM, + COMMAND_FORMS, COMMAND_HELP, + COMMAND_KUDOS, + COMMAND_MOOD, COMMAND_REPORT, COMMAND_STATUS, + COMMAND_TEAM, + COMMAND_TEAMS, + COMMAND_TIMEOFF, + COMMAND_USERS, EXIT_COMMANDS, HELP_TEXT, KNOWN_COMMANDS, + TERMINAL_COMMANDS, parse_command, + parse_command_args, ) from dailybot_cli.tui.conversation import ConversationSession +from dailybot_cli.tui.intents import ( + KudosIntent, + TerminalActionIntent, + TerminalCheckinIntent, + is_checkins_intent, + matching_teams, + matching_users, + parse_kudos_intent, + parse_terminal_action_intent, + parse_terminal_checkin_intent, +) INPUT_HISTORY_LIMIT: int = 100 @@ -120,6 +144,9 @@ def __init__(self, client: DailyBotClient) -> None: self.input_history: list[str] = [] self.input_history_index: int | None = None self.input_history_draft: str = "" + self.pending_flow: dict[str, Any] | None = None + self.completion_state: dict[str, Any] | None = None + self.mention_completion_items: list[str] | None = None def compose(self) -> ComposeResult: with Container(id="shell"): @@ -157,12 +184,31 @@ def on_key(self, event: Key) -> None: event.prevent_default() event.stop() self._show_next_input() + return + if event.key == "tab": + event.prevent_default() + event.stop() + self.run_worker( + self._complete_prompt(reverse=bool(getattr(event, "shift", False))), + exclusive=True, + ) + return + if event.key in {"shift+tab", "shift_tab", "backtab"}: + event.prevent_default() + event.stop() + self.run_worker(self._complete_prompt(reverse=True), exclusive=True) async def on_input_submitted(self, event: Input.Submitted) -> None: prompt = self.query_one("#prompt", Input) raw_value: str = event.value.strip() prompt.value = "" - if not raw_value: + if not raw_value and self.pending_flow is None: + return + + if self.pending_flow is not None: + self._remember_input(raw_value) + self._write_user(raw_value) + await self._handle_pending_flow(raw_value) return if self.report_mode: @@ -171,15 +217,40 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: return self._remember_input(raw_value) + checkin_intent: TerminalCheckinIntent | None = parse_terminal_checkin_intent(raw_value) + if checkin_intent is not None: + self._write_user(raw_value) + await self._handle_terminal_checkin_intent(checkin_intent) + return + command: str | None = parse_command(raw_value) if command is not None: - await self._handle_command(command) + await self._handle_command(command, raw_value) + return + + if is_checkins_intent(raw_value): + self._write_user(raw_value) + await self._start_checkins_flow() + return + + kudos_intent: KudosIntent | None = parse_kudos_intent(raw_value) + if kudos_intent is not None: + self._write_user(raw_value) + await self._start_kudos_flow(kudos_intent) + return + + action_intent: TerminalActionIntent | None = parse_terminal_action_intent(raw_value) + if action_intent is not None: + self._write_user(raw_value) + await self._handle_terminal_action_intent(action_intent) return await self._send_turn(raw_value) def action_clear(self) -> None: self.session.clear() + self.pending_flow = None + self.report_mode = False log = self.query_one("#transcript", RichLog) log.clear() self._write_intro() @@ -200,7 +271,8 @@ def _write_intro(self) -> None: ) log.write(intro) - async def _handle_command(self, command: str) -> None: + async def _handle_command(self, command: str, raw_value: str = "") -> None: + args: list[str] = parse_command_args(raw_value) if command in EXIT_COMMANDS: self.exit() return @@ -214,7 +286,34 @@ async def _handle_command(self, command: str) -> None: await self._show_status() return if command == COMMAND_CHECKINS: - await self._show_checkins() + await self._start_checkins_flow() + return + if command == COMMAND_KUDOS: + await self._start_kudos_menu() + return + if command in {COMMAND_FORMS, COMMAND_FORM}: + await self._handle_form_command(args) + return + if command == COMMAND_USERS: + await self._show_users() + return + if command == COMMAND_TEAMS: + await self._show_teams() + return + if command == COMMAND_TEAM: + await self._start_team_detail_flow(" ".join(args)) + return + if command == COMMAND_DASHBOARD: + self._show_dashboard() + return + if command == COMMAND_MOOD: + await self._start_mood_flow() + return + if command == COMMAND_TIMEOFF: + self._write_dailybot( + "The native time-off terminal flow is not available yet. " + "Use Dailybot chat or the web app for time off until that API is exposed to the CLI." + ) return if command == COMMAND_REPORT: self.report_mode = True @@ -223,6 +322,65 @@ async def _handle_command(self, command: str) -> None: if command not in KNOWN_COMMANDS: self._write_error(f"Unknown command `/{command}`. Type `/help` for commands.") + async def _handle_terminal_checkin_intent(self, intent: TerminalCheckinIntent) -> None: + if intent.action == "complete": + await self._start_checkins_flow() + return + if intent.action == "edit": + await self._start_checkin_edit_flow() + return + if intent.action in {"reset", "delete"}: + await self._start_checkin_reset_flow() + return + self._write_error("Unsupported check-in action. Type `/checkins` to complete one.") + + async def _handle_pending_flow(self, raw_value: str) -> None: + if raw_value.lower() in {"cancel", "q", "quit", "exit"}: + self.pending_flow = None + self._write_system("Cancelled.") + self._set_prompt_hint() + return + command: str | None = parse_command(raw_value) + if command == COMMAND_CLEAR: + self.action_clear() + return + + assert self.pending_flow is not None + flow_type: str = str(self.pending_flow.get("type")) + handlers: dict[str, Any] = { + "checkin_select": self._select_checkin, + "checkin_answer": self._answer_checkin_question, + "checkin_edit_select": self._select_checkin_to_edit, + "checkin_edit_answer": self._answer_checkin_edit_question, + "checkin_reset_select": self._select_checkin_to_reset, + "checkin_reset_confirm": self._confirm_checkin_reset, + "kudos_receiver_kind": self._select_kudos_receiver_kind, + "kudos_user_select": self._select_kudos_user, + "kudos_team_select": self._select_kudos_team, + "kudos_message": self._send_kudos_message, + "kudos_value": self._set_kudos_value, + "kudos_confirm": self._confirm_kudos, + "form_select": self._select_form_action, + "form_action": self._select_form_action_choice, + "form_submit_answer": self._answer_form_submit_question, + "form_response_select": self._select_form_response, + "form_update_answer": self._answer_form_update_question, + "form_transition_select": self._select_form_transition, + "form_transition_note": self._set_form_transition_note, + "form_delete_confirm": self._confirm_form_delete, + "team_detail_select": self._select_team_detail, + "mood_select": self._select_mood, + "terminal_action_confirm": self._confirm_terminal_action, + } + handler: Any = handlers.get(flow_type) + if handler is not None: + await handler(raw_value) + return + + self.pending_flow = None + self._write_error("That terminal menu expired. Try the command again.") + self._set_prompt_hint() + async def _send_turn(self, message: str) -> None: self._write_user(message) self._set_loading(True) @@ -232,6 +390,7 @@ async def _send_turn(self, message: str) -> None: message=message, history=self.session.recent_history(), session_id=self.session.session_id, + available_commands=list(TERMINAL_COMMANDS), ) except httpx.TimeoutException: self._write_error("Dailybot took longer than expected to answer. Please try again.") @@ -253,13 +412,15 @@ async def _send_turn(self, message: str) -> None: actions: list[dict[str, Any]] = response.get("actions") or [] if actions: - action_names: str = ", ".join(str(action.get("name", "action")) for action in actions) - self._write_system(f"Suggested action: {action_names}") + await self._handle_chat_actions(actions) async def _show_status(self) -> None: self._set_loading(True) try: - data = await asyncio.to_thread(self.client.auth_status) + data, status_data = await asyncio.gather( + asyncio.to_thread(self.client.auth_status), + asyncio.to_thread(self.client.get_status), + ) except APIError as exc: self._write_error(self._format_api_error(exc)) return @@ -273,11 +434,22 @@ async def _show_status(self) -> None: ) org_raw: Any = data.get("organization", "") org_name: str = org_raw.get("name", "") if isinstance(org_raw, dict) else str(org_raw) + pending_checkins: list[dict[str, Any]] = status_data.get("pending_checkins", []) + checkin_lines: list[str] = [ + f"- {self._checkin_label(checkin)}" for checkin in pending_checkins[:5] + ] + if len(pending_checkins) > 5: + checkin_lines.append(f"- ...and {len(pending_checkins) - 5} more") + checkins_text: str = "\n".join(checkin_lines) if checkin_lines else "- None pending" self._write_dailybot( - f"**Session status**\n\n- User: {email or 'Unknown'}\n- Org: {org_name}" + "**Session status**\n\n" + f"- User: {email or 'Unknown'}\n" + f"- Org: {org_name}\n" + f"- Pending check-ins: {len(pending_checkins)}\n\n" + f"**Pending check-ins**\n{checkins_text}" ) - async def _show_checkins(self) -> None: + async def _start_checkins_flow(self) -> None: self._set_loading(True) try: data = await asyncio.to_thread(self.client.get_status) @@ -290,15 +462,1228 @@ async def _show_checkins(self) -> None: if not checkins: self._write_dailybot("No pending check-ins for today.") return - lines: list[str] = ["**Pending check-ins**"] - for checkin in checkins: - name = str(checkin.get("followup_name") or "Check-in") - question_count = len(checkin.get("template_questions", [])) - lines.append( - f"- {name} ({question_count} question{'s' if question_count != 1 else ''})" + self.pending_flow = {"type": "checkin_select", "items": checkins} + self._write_dailybot( + self._format_numbered_menu( + "Select a check-in to complete:", + checkins, + self._checkin_label, + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _start_checkin_edit_flow(self) -> None: + self._set_loading(True) + try: + candidates = await asyncio.to_thread(self._load_editable_checkin_candidates) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + if not candidates: + self._write_dailybot("No submitted check-ins found for today.") + return + self.pending_flow = {"type": "checkin_edit_select", "items": candidates} + self._write_dailybot( + self._format_numbered_menu( + "Select a submitted check-in to edit:", + candidates, + self._checkin_edit_label, + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + def _load_editable_checkin_candidates(self) -> list[dict[str, Any]]: + today: str = date.today().isoformat() + candidates: list[dict[str, Any]] = [] + for checkin in self.client.list_checkins(): + followup_uuid: str = str(checkin.get("id") or checkin.get("followup_uuid") or "") + if not followup_uuid: + continue + responses = self.client.list_checkin_responses( + followup_uuid, + date_start=today, + date_end=today, + ) + if responses: + candidates.append({"checkin": checkin, "response": responses[0]}) + return candidates + + async def _select_checkin_to_edit(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + + self._set_loading(True) + try: + edit_context = await asyncio.to_thread(self._load_checkin_edit_context, selected) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + questions: list[dict[str, Any]] = edit_context["questions"] + if not questions: + self.pending_flow = None + self._set_prompt_hint() + self._write_error("Selected check-in has no editable questions.") + return + self.pending_flow = { + "type": "checkin_edit_answer", + "checkin": edit_context["checkin"], + "questions": questions, + "existing_responses": edit_context["existing_responses"], + "answers": [], + "index": 0, + } + self._ask_current_checkin_edit_question() + + def _load_checkin_edit_context(self, selected: dict[str, Any]) -> dict[str, Any]: + checkin: dict[str, Any] = selected["checkin"] + followup_uuid: str = str(checkin.get("id") or checkin.get("followup_uuid") or "") + checkin_detail = self.client.get_checkin(followup_uuid) + template_uuid: str = str(checkin_detail.get("template") or "") + if not template_uuid: + return {"checkin": checkin_detail, "questions": [], "existing_responses": []} + template = self.client.get_template(template_uuid, followup_uuid=followup_uuid) + questions: list[dict[str, Any]] = (template.get("questions") or {}).get("fields") or [] + existing_responses: list[dict[str, Any]] = selected.get("response", {}).get("responses") or [] + return { + "checkin": checkin_detail, + "questions": questions, + "existing_responses": existing_responses, + } + + async def _select_checkin(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + self._set_loading(True) + try: + questions = await asyncio.to_thread(self._load_checkin_questions, selected) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + if not questions: + self.pending_flow = None + self._set_prompt_hint() + self._write_error("Selected check-in has no questions.") + return + self.pending_flow = { + "type": "checkin_answer", + "checkin": selected, + "questions": questions, + "answers": [], + "index": 0, + } + self._ask_current_checkin_question() + + def _load_checkin_questions(self, checkin: dict[str, Any]) -> list[dict[str, Any]]: + followup_uuid: str = str(checkin.get("followup_uuid") or checkin.get("id") or "") + if not followup_uuid: + return checkin.get("template_questions") or [] + checkin_detail = self.client.get_checkin(followup_uuid) + template_uuid: str = str(checkin_detail.get("template") or "") + if not template_uuid: + return checkin.get("template_questions") or [] + template = self.client.get_template(template_uuid, followup_uuid=followup_uuid) + rendered_questions: list[dict[str, Any]] = (template.get("questions") or {}).get("fields") or [] + return rendered_questions or checkin.get("template_questions") or [] + + async def _answer_checkin_question(self, raw_value: str) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + answer: Any = self._parse_question_answer(raw_value, question) + if answer is None: + return + self.pending_flow["answers"].append(answer) + self.pending_flow["index"] = index + 1 + if self.pending_flow["index"] < len(questions): + self._ask_current_checkin_question() + return + + checkin: dict[str, Any] = self.pending_flow["checkin"] + responses: list[dict[str, Any]] = self._build_checkin_responses( + questions, + self.pending_flow["answers"], + ) + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.complete_checkin, + str(checkin.get("followup_uuid") or ""), + responses, + len(responses) - 1, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — submitted **{self._checkin_name(checkin)}**.") + + async def _answer_checkin_edit_question(self, raw_value: str) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + existing_value: Any = self._existing_response_value(index) + answer: Any + if raw_value == "": + answer = existing_value + else: + answer = self._parse_question_answer(raw_value, question) + if answer is None: + return + + self.pending_flow["answers"].append(answer) + self.pending_flow["index"] = index + 1 + if self.pending_flow["index"] < len(questions): + self._ask_current_checkin_edit_question() + return + + checkin: dict[str, Any] = self.pending_flow["checkin"] + responses: list[dict[str, Any]] = self._build_checkin_responses( + questions, + self.pending_flow["answers"], + ) + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.update_checkin_response, + str(checkin.get("id") or checkin.get("followup_uuid") or ""), + responses, + len(responses) - 1, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — updated **{self._checkin_name(checkin)}**.") + + async def _start_kudos_menu(self) -> None: + self._set_loading(True) + try: + users, teams, current_uuid = await asyncio.gather( + asyncio.to_thread(self.client.list_users), + asyncio.to_thread(self.client.list_teams), + asyncio.to_thread(self._get_current_user_uuid), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + self.pending_flow = { + "type": "kudos_receiver_kind", + "users": users, + "teams": teams, + "current_uuid": current_uuid, + } + self._write_dailybot("Who should receive kudos?\n1. One or more users\n2. One or more teams") + self._set_prompt_hint("Type 1 or 2, or `cancel`.") + + async def _start_kudos_flow(self, intent: KudosIntent) -> None: + self._set_loading(True) + try: + users, teams, current_uuid = await asyncio.gather( + asyncio.to_thread(self.client.list_users), + asyncio.to_thread(self.client.list_teams), + asyncio.to_thread(self._get_current_user_uuid), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + user_candidates: list[dict[str, Any]] = [ + user + for user in matching_users(users, intent.receiver_query) + if str(user.get("uuid") or "") != str(current_uuid or "") + ] + team_candidates: list[dict[str, Any]] = matching_teams(teams, intent.receiver_query) + if intent.receiver_kind == "team" or (not user_candidates and team_candidates): + if not team_candidates: + self._write_error(f"I couldn't find a team matching `{intent.receiver_query}`.") + return + self._prompt_kudos_team_selection(team_candidates, intent.message) + return + + if not user_candidates: + self._write_error(f"I couldn't find a teammate matching `{intent.receiver_query}`.") + return + self._prompt_kudos_user_selection(user_candidates, intent.message, current_uuid) + + async def _select_kudos_receiver_kind(self, raw_value: str) -> None: + assert self.pending_flow is not None + value: str = raw_value.strip().lower() + if value not in {"1", "2", "user", "users", "team", "teams"}: + self._write_error("Type 1 for users or 2 for teams.") + return + if value in {"1", "user", "users"}: + users: list[dict[str, Any]] = [ + user + for user in self.pending_flow.get("users", []) + if str(user.get("uuid") or "") != str(self.pending_flow.get("current_uuid") or "") + ] + self._prompt_kudos_user_selection( + users, + "", + self.pending_flow.get("current_uuid"), + ) + return + self._prompt_kudos_team_selection(self.pending_flow.get("teams", []), "") + + def _prompt_kudos_user_selection( + self, + candidates: list[dict[str, Any]], + message: str, + current_uuid: str | None, + ) -> None: + if not candidates: + self.pending_flow = None + self._set_prompt_hint() + self._write_error("No teammates are available for kudos.") + return + self.pending_flow = { + "type": "kudos_user_select", + "items": candidates, + "message": message, + "current_uuid": current_uuid, + } + self._write_dailybot( + self._format_numbered_menu( + "Who should receive kudos? You can type multiple numbers like `1,3`.", + candidates, + self._user_label, + ) + ) + self._set_prompt_hint("Type one or more numbers, or `cancel`.") + + def _prompt_kudos_team_selection( + self, + candidates: list[dict[str, Any]], + message: str, + ) -> None: + if not candidates: + self.pending_flow = None + self._set_prompt_hint() + self._write_error("No teams are visible to you.") + return + self.pending_flow = { + "type": "kudos_team_select", + "items": candidates, + "message": message, + } + self._write_dailybot( + self._format_numbered_menu( + "Which team should receive kudos? You can type multiple numbers like `1,3`.", + candidates, + self._team_label, + ) + ) + self._set_prompt_hint("Type one or more numbers, or `cancel`.") + + async def _select_kudos_user(self, raw_value: str) -> None: + selected: list[dict[str, Any]] | None = self._select_numbered_items(raw_value) + if selected is None: + return + assert self.pending_flow is not None + message: str = str(self.pending_flow.get("message") or "").strip() + current_uuid: str | None = self.pending_flow.get("current_uuid") + selected = [ + user for user in selected if str(user.get("uuid") or "") != str(current_uuid or "") + ] + if not selected: + self._write_error("You cannot give kudos to yourself.") + return + if not message: + self.pending_flow = { + "type": "kudos_message", + "users": selected, + "teams": [], + "current_uuid": current_uuid, + } + self._write_dailybot( + f"What should the kudos message say for **{self._join_labels(selected, self._user_label)}**?" + ) + self._set_prompt_hint("Type the kudos message, or `cancel`.") + return + self._ask_kudos_value(selected, [], message, current_uuid=current_uuid) + + async def _select_kudos_team(self, raw_value: str) -> None: + selected: list[dict[str, Any]] | None = self._select_numbered_items(raw_value) + if selected is None: + return + assert self.pending_flow is not None + message: str = str(self.pending_flow.get("message") or "").strip() + if not message: + self.pending_flow = { + "type": "kudos_message", + "users": [], + "teams": selected, + } + self._write_dailybot( + f"What should the kudos message say for **{self._join_labels(selected, self._team_label)}**?" + ) + self._set_prompt_hint("Type the kudos message, or `cancel`.") + return + self._ask_kudos_value([], selected, message, current_uuid=None) + + async def _send_kudos_message(self, raw_value: str) -> None: + message: str = raw_value.strip() + if not message: + self._write_error("Empty kudos message. Type a message or `cancel`.") + return + assert self.pending_flow is not None + self._ask_kudos_value( + self.pending_flow.get("users") or [], + self.pending_flow.get("teams") or [], + message, + current_uuid=self.pending_flow.get("current_uuid"), + ) + + def _ask_kudos_value( + self, + users: list[dict[str, Any]], + teams: list[dict[str, Any]], + message: str, + *, + current_uuid: str | None = None, + ) -> None: + self.pending_flow = { + "type": "kudos_value", + "users": users, + "teams": teams, + "message": message, + "current_uuid": current_uuid, + } + self._write_dailybot("Optional: type a company value for this kudos, or press Enter to skip.") + self._set_prompt_hint("Type a value, press Enter to skip, or `cancel`.") + + async def _set_kudos_value(self, raw_value: str) -> None: + assert self.pending_flow is not None + self.pending_flow["company_value"] = raw_value.strip() or None + self.pending_flow["type"] = "kudos_confirm" + self._write_dailybot(self._kudos_confirmation_text(self.pending_flow)) + self._set_prompt_hint("Type 1 to send, 2 to cancel.") + + async def _confirm_kudos(self, raw_value: str) -> None: + assert self.pending_flow is not None + normalized: str = raw_value.strip().lower() + if normalized not in {"1", "yes", "y", "send", "2", "no", "n"}: + self._write_error("Type 1 to send or 2 to cancel.") + return + if normalized in {"2", "no", "n"}: + self.pending_flow = None + self._set_prompt_hint() + self._write_system("Kudos cancelled.") + return + users: list[dict[str, Any]] = self.pending_flow.get("users") or [] + teams: list[dict[str, Any]] = self.pending_flow.get("teams") or [] + message: str = str(self.pending_flow.get("message") or "") + company_value: str | None = self.pending_flow.get("company_value") + user_uuids: list[str] = [str(user.get("uuid") or "") for user in users if user.get("uuid")] + team_uuids: list[str] = [str(team.get("uuid") or team.get("id") or "") for team in teams if team.get("uuid") or team.get("id")] + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.give_kudos, + content=message, + user_uuid_receivers=user_uuids or None, + team_uuid_receivers=team_uuids or None, + company_value=company_value, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + receiver_labels: list[str] = [ + *(self._user_label(user) for user in users), + *(self._team_label(team) for team in teams), + ] + self._write_dailybot( + f"Done — kudos sent to **{', '.join(receiver_labels)}** for {message}." + ) + + async def _handle_form_command(self, args: list[str]) -> None: + action: str | None = args[0].lower() if args else None + aliases: dict[str, str] = { + "list": "list", + "submit": "submit", + "responses": "responses", + "response": "responses", + "update": "update", + "edit": "update", + "transition": "transition", + "delete": "delete", + "remove": "delete", + } + if action is not None and action not in aliases: + self._write_error("Unknown form action. Type `/help` for form commands.") + return + await self._start_forms_flow(aliases.get(action) if action else None) + + async def _start_forms_flow(self, action: str | None = None) -> None: + self._set_loading(True) + try: + forms = await asyncio.to_thread(self.client.list_forms, include_questions=True) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + if not forms: + self._write_dailybot("No forms are visible to you.") + return + if action == "list": + self._write_dailybot( + self._format_numbered_menu("Visible forms:", forms, self._form_label) + ) + return + self.pending_flow = {"type": "form_select", "items": forms, "action": action} + heading: str = "Select a form:" + if action: + heading = f"Select a form to {action}:" + self._write_dailybot(self._format_numbered_menu(heading, forms, self._form_label)) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_form_action(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + assert self.pending_flow is not None + action: str | None = self.pending_flow.get("action") + if action: + await self._prepare_form_action(selected, action) + return + self.pending_flow = { + "type": "form_action", + "form": selected, + "items": [ + {"action": "submit", "label": "Submit a response"}, + {"action": "responses", "label": "Browse responses"}, + {"action": "update", "label": "Update a response"}, + {"action": "transition", "label": "Transition a workflow response"}, + {"action": "delete", "label": "Delete a response"}, + ], + } + self._write_dailybot( + self._format_numbered_menu( + f"What do you want to do with **{self._form_label(selected)}**?", + self.pending_flow["items"], + lambda item: str(item["label"]), + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_form_action_choice(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + assert self.pending_flow is not None + await self._prepare_form_action(self.pending_flow["form"], str(selected["action"])) + + async def _prepare_form_action(self, form: dict[str, Any], action: str) -> None: + if action == "submit": + await self._start_form_submit_flow(form) + return + if action == "responses": + await self._start_form_response_picker(form, purpose="view") + return + if action == "update": + await self._start_form_response_picker(form, purpose="update") + return + if action == "transition": + await self._start_form_response_picker(form, purpose="transition") + return + if action == "delete": + await self._start_form_response_picker(form, purpose="delete") + return + self._write_error("Unsupported form action.") + + async def _start_form_submit_flow(self, form: dict[str, Any]) -> None: + form_data: dict[str, Any] | None = await self._load_form_detail(form) + if form_data is None: + return + questions: list[dict[str, Any]] = self._form_questions(form_data) + if not questions: + self._write_error("Selected form has no answerable questions.") + return + self.pending_flow = { + "type": "form_submit_answer", + "form": form_data, + "questions": questions, + "answers": [], + "index": 0, + } + self._ask_current_form_question() + + async def _answer_form_submit_question(self, raw_value: str) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + answer: Any = self._parse_question_answer(raw_value, question) + if answer is None: + return + self.pending_flow["answers"].append(answer) + self.pending_flow["index"] = index + 1 + if self.pending_flow["index"] < len(questions): + self._ask_current_form_question() + return + + form: dict[str, Any] = self.pending_flow["form"] + content: dict[str, Any] = self._build_form_content(questions, self.pending_flow["answers"]) + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.submit_form_response, + str(form.get("id") or form.get("uuid") or ""), + content, ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — submitted **{self._form_label(form)}**.") + + async def _start_form_response_picker(self, form: dict[str, Any], *, purpose: str) -> None: + form_uuid: str = str(form.get("id") or form.get("uuid") or "") + self._set_loading(True) + try: + form_data, responses = await asyncio.gather( + asyncio.to_thread(self.client.get_form, form_uuid), + asyncio.to_thread(self.client.list_form_responses, form_uuid), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + if not responses: + self._write_dailybot(f"No responses found for **{self._form_label(form_data)}**.") + return + self.pending_flow = { + "type": "form_response_select", + "form": form_data, + "items": responses, + "purpose": purpose, + } + self._write_dailybot( + self._format_numbered_menu( + f"Select a response to {purpose}:", + responses, + self._form_response_label, + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_form_response(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + assert self.pending_flow is not None + form: dict[str, Any] = self.pending_flow["form"] + purpose: str = str(self.pending_flow.get("purpose") or "view") + response_uuid: str = str(selected.get("id") or selected.get("uuid") or "") + form_uuid: str = str(form.get("id") or form.get("uuid") or "") + self._set_loading(True) + try: + response = await asyncio.to_thread( + self.client.get_form_response, + form_uuid, + response_uuid, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + + if purpose == "view": + self.pending_flow = None + self._set_prompt_hint() + self._write_dailybot(self._form_response_detail_text(response, form)) + return + if purpose == "update": + await self._start_form_update_flow(form, response) + return + if purpose == "transition": + self._start_form_transition_flow(form, response) + return + if purpose == "delete": + self._start_form_delete_flow(form, response) + return + + async def _start_form_update_flow( + self, + form: dict[str, Any], + response: dict[str, Any], + ) -> None: + questions: list[dict[str, Any]] = self._form_questions(form) + if not questions: + self.pending_flow = None + self._set_prompt_hint() + self._write_error("Selected form has no editable questions.") + return + self.pending_flow = { + "type": "form_update_answer", + "form": form, + "response": response, + "questions": questions, + "answers": [], + "index": 0, + } + self._ask_current_form_update_question() + + async def _answer_form_update_question(self, raw_value: str) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + existing_value: Any = self._existing_form_response_value(question) + answer: Any = existing_value if raw_value == "" else self._parse_question_answer(raw_value, question) + if answer is None: + return + self.pending_flow["answers"].append(answer) + self.pending_flow["index"] = index + 1 + if self.pending_flow["index"] < len(questions): + self._ask_current_form_update_question() + return + + form: dict[str, Any] = self.pending_flow["form"] + response: dict[str, Any] = self.pending_flow["response"] + content: dict[str, Any] = self._build_form_content(questions, self.pending_flow["answers"]) + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.update_form_response, + str(form.get("id") or form.get("uuid") or ""), + str(response.get("id") or response.get("uuid") or ""), + content, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — updated response **{self._form_response_id(response)}**.") + + def _start_form_transition_flow(self, form: dict[str, Any], response: dict[str, Any]) -> None: + transitions: list[dict[str, Any]] = list(response.get("allowed_transitions") or []) + if not transitions: + self.pending_flow = None + self._set_prompt_hint() + self._write_dailybot("This response has no allowed workflow transitions.") + return + self.pending_flow = { + "type": "form_transition_select", + "form": form, + "response": response, + "items": transitions, + } + self._write_dailybot( + self._format_numbered_menu( + f"Select the next state for response **{self._form_response_id(response)}**:", + transitions, + self._transition_label, + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_form_transition(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + assert self.pending_flow is not None + self.pending_flow["transition"] = selected + self.pending_flow["type"] = "form_transition_note" + self._write_dailybot("Optional: type a transition note, or press Enter to skip.") + self._set_prompt_hint("Type a note, press Enter to skip, or `cancel`.") + + async def _set_form_transition_note(self, raw_value: str) -> None: + assert self.pending_flow is not None + form: dict[str, Any] = self.pending_flow["form"] + response: dict[str, Any] = self.pending_flow["response"] + transition: dict[str, Any] = self.pending_flow["transition"] + note: str | None = raw_value.strip() or None + to_state: str = str(transition.get("to_state") or transition.get("key") or "") + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.transition_form_response, + str(form.get("id") or form.get("uuid") or ""), + str(response.get("id") or response.get("uuid") or ""), + to_state, + note, + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot( + f"Done — moved response **{self._form_response_id(response)}** to **{self._transition_label(transition)}**." + ) + + def _start_form_delete_flow(self, form: dict[str, Any], response: dict[str, Any]) -> None: + self.pending_flow = { + "type": "form_delete_confirm", + "form": form, + "response": response, + } + self._write_dailybot( + f"Delete response **{self._form_response_id(response)}** from **{self._form_label(form)}**?\n" + "1. Delete\n2. Cancel" + ) + self._set_prompt_hint("Type 1 to delete, 2 to cancel.") + + async def _confirm_form_delete(self, raw_value: str) -> None: + assert self.pending_flow is not None + normalized: str = raw_value.strip().lower() + if normalized not in {"1", "yes", "y", "delete", "2", "no", "n"}: + self._write_error("Type 1 to delete or 2 to cancel.") + return + if normalized in {"2", "no", "n"}: + self.pending_flow = None + self._set_prompt_hint() + self._write_system("Delete cancelled.") + return + form: dict[str, Any] = self.pending_flow["form"] + response: dict[str, Any] = self.pending_flow["response"] + form_uuid: str = str(form.get("id") or form.get("uuid") or "") + response_uuid: str = str(response.get("id") or response.get("uuid") or "") + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread(self.client.delete_form_response, form_uuid, response_uuid) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — deleted response **{response_uuid}**.") + + async def _load_form_detail(self, form: dict[str, Any]) -> dict[str, Any] | None: + form_uuid: str = str(form.get("id") or form.get("uuid") or "") + if not form_uuid: + self._write_error("Selected form has no UUID.") + return None + if self._form_questions(form): + return form + self._set_loading(True) + try: + return await asyncio.to_thread(self.client.get_form, form_uuid) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return None + finally: + self._set_loading(False) + + def _ask_current_form_question(self) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + lines: list[str] = [f"Question {index + 1}/{len(questions)}: {self._question_label(question, index)}"] + choices: list[str] = self._question_choices(question) + if self._question_type(question) == "boolean": + lines.extend(["1. Yes", "2. No"]) + self._set_prompt_hint("Type 1 for yes, 2 for no, or `cancel`.") + elif choices: + for choice_index, choice in enumerate(choices, start=1): + lines.append(f"{choice_index}. {choice}") + self._set_prompt_hint("Type a number, or `cancel`.") + else: + self._set_prompt_hint("Type your answer, or `cancel`.") + self._write_dailybot("\n".join(lines)) + + def _ask_current_form_update_question(self) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + existing_value: Any = self._existing_form_response_value(question) + lines: list[str] = [ + f"Question {index + 1}/{len(questions)}: {self._question_label(question, index)}", + f"Current answer: {self._format_answer(existing_value)}", + ] + choices: list[str] = self._question_choices(question) + if self._question_type(question) == "boolean": + lines.extend(["1. Yes", "2. No"]) + self._set_prompt_hint("Type 1/2, Enter to keep, or `cancel`.") + elif choices: + for choice_index, choice in enumerate(choices, start=1): + lines.append(f"{choice_index}. {choice}") + self._set_prompt_hint("Type a number, Enter to keep, or `cancel`.") + else: + self._set_prompt_hint("Type a replacement, Enter to keep, or `cancel`.") self._write_dailybot("\n".join(lines)) + def _existing_form_response_value(self, question: dict[str, Any]) -> Any: + assert self.pending_flow is not None + response: dict[str, Any] = self.pending_flow.get("response") or {} + content: dict[str, Any] = response.get("content") if isinstance(response.get("content"), dict) else {} + question_uuid: str = str(question.get("uuid") or question.get("id") or "") + return content.get(question_uuid, "") + + @staticmethod + def _form_questions(form: dict[str, Any]) -> list[dict[str, Any]]: + questions: Any = form.get("questions") or [] + if isinstance(questions, dict): + fields: Any = questions.get("fields") or [] + return list(fields) if isinstance(fields, list) else [] + return list(questions) if isinstance(questions, list) else [] + + @staticmethod + def _build_form_content( + questions: list[dict[str, Any]], + answers: list[Any], + ) -> dict[str, Any]: + content: dict[str, Any] = {} + for question, answer in zip(questions, answers, strict=True): + question_uuid: str = str(question.get("uuid") or question.get("id") or "") + if question_uuid: + content[question_uuid] = answer + return content + + @classmethod + def _form_label(cls, form: dict[str, Any]) -> str: + questions_count: int = len(cls._form_questions(form)) + suffix: str = f" ({questions_count} question{'s' if questions_count != 1 else ''})" + return str(form.get("name") or form.get("title") or form.get("uuid") or form.get("id") or "Form") + suffix + + @classmethod + def _form_response_label(cls, response: dict[str, Any]) -> str: + response_id: str = cls._form_response_id(response) + state: str = str(response.get("current_state") or "no state") + created_at: str = str(response.get("created_at") or response.get("updated_at") or "") + suffix: str = f" — {created_at[:10]}" if created_at else "" + return f"{response_id} ({state}){suffix}" + + @staticmethod + def _form_response_id(response: dict[str, Any]) -> str: + return str(response.get("id") or response.get("uuid") or "response") + + @staticmethod + def _transition_label(transition: dict[str, Any]) -> str: + return str( + transition.get("label") + or transition.get("to_state") + or transition.get("key") + or "Next state" + ) + + def _form_response_detail_text( + self, + response: dict[str, Any], + form: dict[str, Any], + ) -> str: + lines: list[str] = [ + f"**Response {self._form_response_id(response)}**", + f"- Form: {self._form_label(form)}", + f"- Current state: {response.get('current_state') or 'None'}", + ] + content: Any = response.get("content") + if isinstance(content, dict) and content: + lines.append("\n**Answers**") + for question_uuid, answer in content.items(): + lines.append(f"- {question_uuid}: {self._format_answer(answer)}") + transitions: list[dict[str, Any]] = list(response.get("allowed_transitions") or []) + if transitions: + lines.append("\n**Allowed transitions**") + for transition in transitions: + lines.append(f"- {self._transition_label(transition)}") + return "\n".join(lines) + + async def _show_users(self) -> None: + self._set_loading(True) + try: + users = await asyncio.to_thread(self.client.list_users) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + if not users: + self._write_dailybot("No users are visible to you.") + return + self._write_dailybot( + self._format_numbered_menu( + f"Organization members ({len(users)}):", + users[:25], + self._user_label, + ) + ) + + async def _show_teams(self) -> None: + self._set_loading(True) + try: + teams = await asyncio.to_thread(self.client.list_teams) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + if not teams: + self._write_dailybot("No teams are visible to you.") + return + self._write_dailybot( + self._format_numbered_menu(f"Teams ({len(teams)}):", teams[:25], self._team_label) + ) + + async def _start_team_detail_flow(self, query: str = "") -> None: + self._set_loading(True) + try: + teams = await asyncio.to_thread(self.client.list_teams) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + candidates: list[dict[str, Any]] = matching_teams(teams, query) if query else teams + if not candidates: + self._write_error(f"No team matching `{query}` is visible to you.") + return + self.pending_flow = {"type": "team_detail_select", "items": candidates} + self._write_dailybot( + self._format_numbered_menu("Select a team to inspect:", candidates, self._team_label) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_team_detail(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + team_uuid: str = str(selected.get("uuid") or selected.get("id") or "") + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + team, members = await asyncio.gather( + asyncio.to_thread(self.client.get_team, team_uuid), + asyncio.to_thread(self.client.list_team_members, team_uuid), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + member_lines: list[str] = [f"- {self._user_label(member)}" for member in members[:25]] + members_text: str = "\n".join(member_lines) if member_lines else "- No members returned" + self._write_dailybot( + f"**{self._team_label(team)}**\n\n" + f"- UUID: {team_uuid}\n" + f"- Members: {len(members)}\n\n" + f"**Members**\n{members_text}" + ) + + def _show_dashboard(self) -> None: + self._write_dailybot( + f"Open your Dailybot dashboard: {self.client.api_url.replace('/api', '').rstrip('/')}/home" + ) + + async def _start_mood_flow(self) -> None: + self.pending_flow = {"type": "mood_select"} + self._write_dailybot( + "How is your mood today?\n" + "1. Very low\n" + "2. Low\n" + "3. Okay\n" + "4. Good\n" + "5. Great" + ) + self._set_prompt_hint("Type a number from 1 to 5, or `cancel`.") + + async def _select_mood(self, raw_value: str) -> None: + try: + score: int = int(raw_value.strip()) + except ValueError: + self._write_error("Type a number from 1 to 5.") + return + if score < 1 or score > 5: + self._write_error("Type a number from 1 to 5.") + return + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread(self.client.track_mood, score) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot("Done — your mood was tracked for today.") + + async def _start_checkin_reset_flow(self) -> None: + self._set_loading(True) + try: + candidates = await asyncio.to_thread(self._load_editable_checkin_candidates) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + if not candidates: + self._write_dailybot("No submitted check-ins found for today.") + return + self.pending_flow = {"type": "checkin_reset_select", "items": candidates} + self._write_dailybot( + self._format_numbered_menu( + "Select a submitted check-in to delete:", + candidates, + self._checkin_edit_label, + ) + ) + self._set_prompt_hint("Type a number, or `cancel`.") + + async def _select_checkin_to_reset(self, raw_value: str) -> None: + selected: dict[str, Any] | None = self._select_numbered_item(raw_value) + if selected is None: + return + self.pending_flow = {"type": "checkin_reset_confirm", "candidate": selected} + self._write_dailybot( + f"Delete today's response for **{self._checkin_edit_label(selected)}**?\n" + "1. Delete\n2. Cancel" + ) + self._set_prompt_hint("Type 1 to delete, 2 to cancel.") + + async def _confirm_checkin_reset(self, raw_value: str) -> None: + assert self.pending_flow is not None + normalized: str = raw_value.strip().lower() + if normalized not in {"1", "yes", "y", "delete", "2", "no", "n"}: + self._write_error("Type 1 to delete or 2 to cancel.") + return + if normalized in {"2", "no", "n"}: + self.pending_flow = None + self._set_prompt_hint() + self._write_system("Check-in delete cancelled.") + return + candidate: dict[str, Any] = self.pending_flow["candidate"] + checkin: dict[str, Any] = candidate.get("checkin") or {} + followup_uuid: str = str(checkin.get("id") or checkin.get("followup_uuid") or "") + self.pending_flow = None + self._set_prompt_hint() + self._set_loading(True) + try: + await asyncio.to_thread( + self.client.delete_checkin_response, + followup_uuid, + response_date=date.today().isoformat(), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + return + finally: + self._set_loading(False) + self._write_dailybot(f"Done — deleted today's response for **{self._checkin_name(checkin)}**.") + + async def _handle_terminal_action_intent(self, intent: TerminalActionIntent) -> None: + await self._start_terminal_flow_by_name(intent.action, {"args": list(intent.args)}) + + async def _handle_chat_actions(self, actions: list[dict[str, Any]]) -> None: + for action in actions: + action_type: str = str(action.get("type") or "") + flow_name: str = str(action.get("flow") or action.get("name") or "") + if action_type == "terminal_flow" and flow_name: + self.pending_flow = { + "type": "terminal_action_confirm", + "flow": flow_name, + "params": action.get("params") or {}, + } + self._write_dailybot( + f"Start the **{flow_name}** terminal flow?\n1. Yes\n2. No" + ) + self._set_prompt_hint("Type 1 to start, 2 to skip.") + return + action_names: str = ", ".join(str(action.get("name", "action")) for action in actions) + self._write_system(f"Suggested action: {action_names}") + + async def _confirm_terminal_action(self, raw_value: str) -> None: + assert self.pending_flow is not None + normalized: str = raw_value.strip().lower() + if normalized not in {"1", "yes", "y", "2", "no", "n"}: + self._write_error("Type 1 to start or 2 to skip.") + return + flow_name: str = str(self.pending_flow.get("flow") or "") + params: dict[str, Any] = self.pending_flow.get("params") or {} + self.pending_flow = None + self._set_prompt_hint() + if normalized in {"2", "no", "n"}: + self._write_system("Skipped suggested action.") + return + await self._start_terminal_flow_by_name(flow_name, params) + + async def _start_terminal_flow_by_name(self, flow_name: str, params: dict[str, Any]) -> None: + normalized: str = flow_name.strip().lower().replace("-", "_") + if normalized in {"checkins", "checkin", "complete_checkin", "form_checkin"}: + await self._start_checkins_flow() + return + if normalized in {"checkin_edit", "edit_checkin"}: + await self._start_checkin_edit_flow() + return + if normalized in {"checkin_reset", "reset_checkin", "checkin_delete"}: + await self._start_checkin_reset_flow() + return + if normalized in {"kudos", "send_kudos"}: + receiver_query: str = str(params.get("receiver_query") or params.get("receivers") or "") + message: str = str(params.get("message") or "") + if receiver_query: + await self._start_kudos_flow(KudosIntent(receiver_query=receiver_query, message=message)) + else: + await self._start_kudos_menu() + return + if normalized in {"forms", "form"}: + await self._start_forms_flow() + return + if normalized.startswith("form_"): + await self._start_forms_flow(normalized.removeprefix("form_")) + return + if normalized == "users": + await self._show_users() + return + if normalized == "teams": + await self._show_teams() + return + if normalized == "team": + await self._start_team_detail_flow(str(params.get("query") or "")) + return + if normalized == "dashboard": + self._show_dashboard() + return + if normalized == "mood": + await self._start_mood_flow() + return + self._write_dailybot("That suggested action is not supported in the terminal yet.") + async def _submit_report(self, message: str) -> None: if message.startswith("/"): command = parse_command(message) @@ -319,6 +1704,286 @@ async def _submit_report(self, message: str) -> None: count = int(result.get("followups_count", 0)) self._write_dailybot(f"Progress update submitted to {count} check-in(s).") + def _select_numbered_item(self, raw_value: str) -> dict[str, Any] | None: + assert self.pending_flow is not None + items: list[dict[str, Any]] = self.pending_flow.get("items") or [] + try: + selected_index: int = int(raw_value.strip()) + except ValueError: + self._write_error(f"Enter a number between 1 and {len(items)}, or `cancel`.") + return None + if selected_index < 1 or selected_index > len(items): + self._write_error(f"Enter a number between 1 and {len(items)}, or `cancel`.") + return None + return items[selected_index - 1] + + def _select_numbered_items(self, raw_value: str) -> list[dict[str, Any]] | None: + assert self.pending_flow is not None + items: list[dict[str, Any]] = self.pending_flow.get("items") or [] + raw_parts: list[str] = [ + part.strip() for part in raw_value.replace(" ", ",").split(",") if part.strip() + ] + if not raw_parts: + self._write_error(f"Enter a number between 1 and {len(items)}, or `cancel`.") + return None + selected: list[dict[str, Any]] = [] + for raw_part in raw_parts: + try: + selected_index: int = int(raw_part) + except ValueError: + self._write_error(f"Enter numbers between 1 and {len(items)}, separated by commas.") + return None + if selected_index < 1 or selected_index > len(items): + self._write_error(f"Enter numbers between 1 and {len(items)}, separated by commas.") + return None + item: dict[str, Any] = items[selected_index - 1] + if item not in selected: + selected.append(item) + return selected + + def _ask_current_checkin_question(self) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + prompt: str = self._question_label(question, index) + choices: list[str] = self._question_choices(question) + lines: list[str] = [f"Question {index + 1}/{len(questions)}: {prompt}"] + if self._question_type(question) == "boolean": + lines.append("1. Yes") + lines.append("2. No") + self._set_prompt_hint("Type 1 for yes, 2 for no, or `cancel`.") + elif choices: + for choice_index, choice in enumerate(choices, start=1): + lines.append(f"{choice_index}. {choice}") + self._set_prompt_hint("Type a number, or `cancel`.") + else: + self._set_prompt_hint("Type your answer, or `cancel`.") + self._write_dailybot("\n".join(lines)) + + def _ask_current_checkin_edit_question(self) -> None: + assert self.pending_flow is not None + questions: list[dict[str, Any]] = self.pending_flow["questions"] + index: int = int(self.pending_flow["index"]) + question: dict[str, Any] = questions[index] + prompt: str = self._question_label(question, index) + choices: list[str] = self._question_choices(question) + existing_value: Any = self._existing_response_value(index) + existing_label: str = self._format_answer(existing_value) + lines: list[str] = [ + f"Question {index + 1}/{len(questions)}: {prompt}", + f"Current answer: {existing_label}", + ] + if self._question_type(question) == "boolean": + lines.append("1. Yes") + lines.append("2. No") + self._set_prompt_hint("Type 1/2, Enter to keep, or `cancel`.") + elif choices: + for choice_index, choice in enumerate(choices, start=1): + lines.append(f"{choice_index}. {choice}") + self._set_prompt_hint("Type a number, Enter to keep, or `cancel`.") + else: + self._set_prompt_hint("Type a replacement, Enter to keep, or `cancel`.") + self._write_dailybot("\n".join(lines)) + + def _existing_response_value(self, index: int) -> Any: + assert self.pending_flow is not None + existing_responses: list[dict[str, Any]] = self.pending_flow.get("existing_responses") or [] + if index < len(existing_responses): + return existing_responses[index].get("response") + return "" + + @staticmethod + def _format_answer(value: Any) -> str: + if value is None or value == "" or value == {} or value == []: + return "_empty_" + if isinstance(value, bool): + return "Yes" if value else "No" + if isinstance(value, dict): + for key in ( + "response", + "value", + "text", + "answer", + "label", + "name", + "title", + "content", + ): + nested_value: Any = value.get(key) + if nested_value not in (None, "", {}, []): + return DailybotChatApp._format_answer(nested_value) + parts: list[str] = [] + for key, nested_value in value.items(): + formatted_value: str = DailybotChatApp._format_answer(nested_value) + if formatted_value != "_empty_": + parts.append(f"{key}: {formatted_value}") + return ", ".join(parts) if parts else "_empty_" + if isinstance(value, list): + parts = [ + DailybotChatApp._format_answer(item) + for item in value + if item not in (None, "", {}, []) + ] + return ", ".join(part for part in parts if part != "_empty_") or "_empty_" + return str(value) + + def _parse_question_answer(self, raw_value: str, question: dict[str, Any]) -> Any: + value: str = raw_value.strip() + question_type: str = self._question_type(question) + choices: list[str] = self._question_choices(question) + + if question_type == "boolean": + normalized: str = value.lower() + if normalized in {"1", "yes", "y", "true"}: + return True + if normalized in {"2", "no", "n", "false"}: + return False + self._write_error("Type 1 for yes or 2 for no.") + return None + + if choices: + try: + selected_index: int = int(value) + except ValueError: + self._write_error(f"Enter a number between 1 and {len(choices)}.") + return None + if selected_index < 1 or selected_index > len(choices): + self._write_error(f"Enter a number between 1 and {len(choices)}.") + return None + return choices[selected_index - 1] + + if question_type == "numeric": + try: + return float(value) if "." in value else int(value) + except ValueError: + self._write_error("Enter a valid number.") + return None + + return value + + @staticmethod + def _build_checkin_responses( + questions: list[dict[str, Any]], + answers: list[Any], + ) -> list[dict[str, Any]]: + responses: list[dict[str, Any]] = [] + for index, (question, answer) in enumerate(zip(questions, answers, strict=True)): + question_uuid: str = str(question.get("uuid") or question.get("id") or "") + responses.append( + { + "uuid": question_uuid, + "index": index, + "response": answer, + } + ) + return responses + + @staticmethod + def _format_numbered_menu( + heading: str, + items: list[dict[str, Any]], + label_fn: Any, + ) -> str: + lines: list[str] = [heading] + for index, item in enumerate(items, start=1): + lines.append(f"{index}. {label_fn(item)}") + return "\n".join(lines) + + @classmethod + def _checkin_label(cls, checkin: dict[str, Any]) -> str: + question_count: int = len(checkin.get("template_questions", [])) + return ( + f"{cls._checkin_name(checkin)} " + f"({question_count} question{'s' if question_count != 1 else ''})" + ) + + @classmethod + def _checkin_edit_label(cls, candidate: dict[str, Any]) -> str: + response: dict[str, Any] = candidate.get("response") or {} + updated_at: str = str(response.get("updated_at") or response.get("created_at") or "") + suffix: str = f" — {updated_at[:10]}" if updated_at else "" + return f"{cls._checkin_name(candidate.get('checkin') or {})}{suffix}" + + @staticmethod + def _checkin_name(checkin: dict[str, Any]) -> str: + return str(checkin.get("followup_name") or checkin.get("name") or "Check-in") + + @staticmethod + def _user_label(user: dict[str, Any]) -> str: + return str( + user.get("full_name") + or user.get("name") + or user.get("display_name") + or user.get("email") + or user.get("uuid") + or "Unknown user" + ) + + @staticmethod + def _team_label(team: dict[str, Any]) -> str: + return str(team.get("name") or team.get("uuid") or team.get("id") or "Unknown team") + + @staticmethod + def _join_labels(items: list[dict[str, Any]], label_fn: Any) -> str: + return ", ".join(label_fn(item) for item in items) + + def _kudos_confirmation_text(self, flow: dict[str, Any]) -> str: + users: list[dict[str, Any]] = flow.get("users") or [] + teams: list[dict[str, Any]] = flow.get("teams") or [] + user_labels: str = self._join_labels(users, self._user_label) if users else "None" + team_labels: str = self._join_labels(teams, self._team_label) if teams else "None" + company_value: str = str(flow.get("company_value") or "None") + return ( + "**Send kudos?**\n\n" + f"- Users: {user_labels}\n" + f"- Teams: {team_labels}\n" + f"- Message: {flow.get('message')}\n" + f"- Company value: {company_value}\n\n" + "1. Send\n2. Cancel" + ) + + @staticmethod + def _question_label(question: dict[str, Any], index: int) -> str: + for key in ("question", "text", "label", "name", "title"): + value: Any = question.get(key) + if value: + return str(value) + return f"Question {index + 1}" + + @staticmethod + def _question_choices(question: dict[str, Any]) -> list[str]: + raw_choices: Any = question.get("choices") or question.get("options") or [] + if not isinstance(raw_choices, list): + return [] + choices: list[str] = [] + for choice in raw_choices: + if isinstance(choice, str): + choices.append(choice) + elif isinstance(choice, dict): + label: Any = choice.get("label") or choice.get("text") or choice.get("value") + if label: + choices.append(str(label)) + return choices + + @staticmethod + def _question_type(question: dict[str, Any]) -> str: + raw_type: str = str(question.get("question_type") or question.get("type") or "") + normalized: str = raw_type.strip().lower().replace("-", "_").replace(" ", "_") + if normalized in {"boolean", "bool", "yes_no", "yes/no", "toggle"}: + return "boolean" + if normalized in {"number", "numeric", "integer", "int", "float", "decimal"}: + return "numeric" + return "text" + + def _get_current_user_uuid(self) -> str | None: + data: dict[str, Any] = self.client.auth_status() + user_raw: Any = data.get("user", {}) + if isinstance(user_raw, dict): + value: Any = user_raw.get("uuid") or user_raw.get("id") + return str(value) if value else None + return None + def _write_user(self, content: str) -> None: log = self.query_one("#transcript", RichLog) log.write(Text(self.user_label, style="bold #7aa2f7")) @@ -350,6 +2015,10 @@ def _set_loading(self, loading: bool) -> None: if not loading: prompt.focus() + def _set_prompt_hint(self, hint: str | None = None) -> None: + prompt = self.query_one("#prompt", Input) + prompt.placeholder = hint or "> Ask Dailybot anything..." + def _remember_input(self, value: str) -> None: if not self.input_history or self.input_history[-1] != value: self.input_history.append(value) @@ -387,6 +2056,160 @@ def _replace_prompt_value(self, value: str) -> None: prompt.value = value prompt.cursor_position = len(value) + async def _complete_prompt(self, *, reverse: bool = False) -> None: + prompt = self.query_one("#prompt", Input) + value: str = prompt.value + cursor_position: int = prompt.cursor_position + + if self._can_cycle_completion(value, cursor_position): + self._cycle_completion(prompt, reverse=reverse) + return + + command_completion: tuple[str, int] | None = self._command_completion( + value, + cursor_position, + reverse=reverse, + ) + if command_completion is not None: + self._apply_completion(prompt, command_completion[0], command_completion[1]) + return + + mention_items: list[str] = await self._load_mention_completion_items() + mention_completion: tuple[str, int] | None = self._mention_completion( + value, + cursor_position, + mention_items, + reverse=reverse, + ) + if mention_completion is not None: + self._apply_completion(prompt, mention_completion[0], mention_completion[1]) + return + self.completion_state = None + + def _command_completion( + self, + value: str, + cursor_position: int, + *, + reverse: bool = False, + ) -> tuple[str, int] | None: + before_cursor: str = value[:cursor_position] + after_cursor: str = value[cursor_position:] + if not before_cursor.startswith("/") or "\n" in before_cursor: + return None + prefix: str = before_cursor.lower() + matches: list[str] = [ + command for command in COMMAND_COMPLETIONS if command.lower().startswith(prefix) + ] + if not matches: + return None + index: int = len(matches) - 1 if reverse else 0 + completed_value: str = matches[index] + after_cursor + self.completion_state = { + "matches": matches, + "index": index, + "last_value": completed_value, + "last_cursor": len(matches[index]), + } + return completed_value, len(matches[index]) + + def _mention_completion( + self, + value: str, + cursor_position: int, + mention_items: list[str], + *, + reverse: bool = False, + ) -> tuple[str, int] | None: + before_cursor: str = value[:cursor_position] + after_cursor: str = value[cursor_position:] + mention_start: int = before_cursor.rfind("@") + if mention_start < 0: + return None + if mention_start > 0 and not before_cursor[mention_start - 1].isspace(): + return None + raw_prefix: str = before_cursor[mention_start + 1 :] + if any(char.isspace() for char in raw_prefix): + return None + prefix: str = raw_prefix.lower() + matches: list[str] = [ + item for item in mention_items if item.removeprefix("@").lower().startswith(prefix) + ] + if not matches: + return None + index: int = len(matches) - 1 if reverse else 0 + completed_value: str = before_cursor[:mention_start] + matches[index] + after_cursor + cursor: int = mention_start + len(matches[index]) + self.completion_state = { + "matches": matches, + "index": index, + "last_value": completed_value, + "last_cursor": cursor, + } + return completed_value, cursor + + async def _load_mention_completion_items(self) -> list[str]: + if self.mention_completion_items is not None: + return self.mention_completion_items + try: + users, teams = await asyncio.gather( + asyncio.to_thread(self.client.list_users), + asyncio.to_thread(self.client.list_teams), + ) + except APIError as exc: + self._write_error(self._format_api_error(exc)) + self.mention_completion_items = [] + return [] + user_items: list[str] = [f"@{self._user_label(user)}" for user in users] + team_items: list[str] = [f"@{self._team_label(team)} team" for team in teams] + self.mention_completion_items = [*user_items, *team_items] + return self.mention_completion_items + + def _can_cycle_completion(self, value: str, cursor_position: int) -> bool: + if self.completion_state is None: + return False + return ( + value == self.completion_state.get("last_value") + and cursor_position == self.completion_state.get("last_cursor") + ) + + def _cycle_completion(self, prompt: Input, *, reverse: bool = False) -> None: + assert self.completion_state is not None + matches: list[str] = self.completion_state.get("matches") or [] + if not matches: + self.completion_state = None + return + step: int = -1 if reverse else 1 + index: int = (int(self.completion_state.get("index") or 0) + step) % len(matches) + value: str = prompt.value + cursor_position: int = prompt.cursor_position + current_match: str = str(matches[int(self.completion_state.get("index") or 0)]) + replacement: str = matches[index] + start: int = max(0, cursor_position - len(current_match)) + completed_value: str = value[:start] + replacement + value[cursor_position:] + self.completion_state["index"] = index + self.completion_state["last_value"] = completed_value + self.completion_state["last_cursor"] = start + len(replacement) + self._set_prompt_completion(prompt, completed_value, start + len(replacement)) + + def _apply_completion(self, prompt: Input, value: str, cursor_position: int) -> None: + if self.completion_state is None: + self.completion_state = { + "matches": [value], + "index": 0, + "last_value": value, + "last_cursor": cursor_position, + } + else: + self.completion_state["last_value"] = value + self.completion_state["last_cursor"] = cursor_position + self._set_prompt_completion(prompt, value, cursor_position) + + @staticmethod + def _set_prompt_completion(prompt: Input, value: str, cursor_position: int) -> None: + prompt.value = value + prompt.cursor_position = cursor_position + def _refresh_status(self) -> None: self.query_one("#status", Static).update( f"Session {self.session.session_id[:8]} | " diff --git a/dailybot_cli/tui/commands.py b/dailybot_cli/tui/commands.py index a39a4c5..231f384 100644 --- a/dailybot_cli/tui/commands.py +++ b/dailybot_cli/tui/commands.py @@ -2,34 +2,92 @@ COMMAND_CHECKINS: Final[str] = "checkins" COMMAND_CLEAR: Final[str] = "clear" +COMMAND_DASHBOARD: Final[str] = "dashboard" COMMAND_EXIT: Final[str] = "exit" +COMMAND_FORM: Final[str] = "form" +COMMAND_FORMS: Final[str] = "forms" COMMAND_HELP: Final[str] = "help" +COMMAND_KUDOS: Final[str] = "kudos" +COMMAND_MOOD: Final[str] = "mood" COMMAND_QUIT: Final[str] = "quit" COMMAND_REPORT: Final[str] = "report" COMMAND_STATUS: Final[str] = "status" +COMMAND_TEAM: Final[str] = "team" +COMMAND_TEAMS: Final[str] = "teams" +COMMAND_TIMEOFF: Final[str] = "timeoff" +COMMAND_USERS: Final[str] = "users" EXIT_COMMANDS: Final[set[str]] = {COMMAND_EXIT, COMMAND_QUIT} KNOWN_COMMANDS: Final[set[str]] = { COMMAND_CHECKINS, COMMAND_CLEAR, + COMMAND_DASHBOARD, COMMAND_EXIT, + COMMAND_FORM, + COMMAND_FORMS, COMMAND_HELP, + COMMAND_KUDOS, + COMMAND_MOOD, COMMAND_QUIT, COMMAND_REPORT, COMMAND_STATUS, + COMMAND_TEAM, + COMMAND_TEAMS, + COMMAND_TIMEOFF, + COMMAND_USERS, } HELP_TEXT: Final[str] = """Commands /help - Show this help. /clear - Clear the local terminal transcript. -/status - Show the current login/session status. -/checkins - Show pending check-ins. +/status - Show login status and pending check-ins. +/dashboard - Show the Dailybot dashboard link. +/checkins - Complete pending check-ins. +/checkin edit - Edit today's submitted check-in. +/checkin reset - Delete today's submitted check-in after confirmation. +/kudos - Send kudos to users or teams. +/forms - List forms and choose an action. +/form submit - Submit a form response. +/form responses - Browse your form responses. +/form update - Update one of your form responses. +/form transition - Move a workflow form response. +/form delete - Delete a form response after confirmation. +/users - Browse organization members. +/teams - Browse teams. +/team - Pick a team and show its members. +/mood - Track today's mood. /report - Submit a free-text progress update. +/timeoff - Time-off native flow is not available in this terminal yet. /exit - Leave the session. Natural language goes to Dailybot. """ +TERMINAL_COMMANDS: Final[list[dict[str, str]]] = [ + {"name": "/help", "description": "Show the command catalog."}, + {"name": "/clear", "description": "Clear the local terminal transcript."}, + {"name": "/status", "description": "Show login status and pending check-ins."}, + {"name": "/dashboard", "description": "Show the Dailybot dashboard URL."}, + {"name": "/checkins", "description": "Complete pending check-ins with numbered prompts."}, + {"name": "/checkin edit", "description": "Edit today's submitted check-in answers."}, + {"name": "/checkin reset", "description": "Delete today's submitted check-in response after confirmation."}, + {"name": "/kudos", "description": "Send kudos to users or teams from the terminal."}, + {"name": "/forms", "description": "List forms and submit or manage responses."}, + {"name": "/form submit", "description": "Submit a form response question by question."}, + {"name": "/form responses", "description": "Browse form responses visible to the user."}, + {"name": "/form update", "description": "Update a form response question by question."}, + {"name": "/form transition", "description": "Move a workflow form response to an allowed state."}, + {"name": "/form delete", "description": "Delete a form response after confirmation."}, + {"name": "/users", "description": "Browse organization members."}, + {"name": "/teams", "description": "Browse teams."}, + {"name": "/team", "description": "Show details and members for a team."}, + {"name": "/mood", "description": "Track today's mood score."}, + {"name": "/report", "description": "Submit a free-text progress update."}, + {"name": "/timeoff", "description": "Explain time-off terminal flow availability."}, + {"name": "/exit", "description": "Leave the session."}, +] +COMMAND_COMPLETIONS: Final[list[str]] = [command["name"] for command in TERMINAL_COMMANDS] + def parse_command(raw_value: str) -> str | None: """Return a normalized slash command, or None for normal chat text.""" @@ -38,3 +96,12 @@ def parse_command(raw_value: str) -> str | None: return None command: str = value[1:].split(maxsplit=1)[0].lower() return command or COMMAND_HELP + + +def parse_command_args(raw_value: str) -> list[str]: + """Return slash-command arguments without the command itself.""" + value: str = raw_value.strip() + if not value.startswith("/"): + return [] + parts: list[str] = value[1:].split() + return parts[1:] diff --git a/dailybot_cli/tui/intents.py b/dailybot_cli/tui/intents.py new file mode 100644 index 0000000..6170a06 --- /dev/null +++ b/dailybot_cli/tui/intents.py @@ -0,0 +1,205 @@ +"""Small deterministic intents for terminal-native chat flows.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +CHECKINS_INTENT_PHRASES: frozenset[str] = frozenset( + { + "checkin", + "check-in", + "checkins", + "check-ins", + "show checkins", + "show check-ins", + "show my checkins", + "show my check-ins", + "list checkins", + "list check-ins", + "my checkins", + "my check-ins", + } +) + +_CHECKINS_QUESTION_RE: re.Pattern[str] = re.compile( + r"\b(what|which|show|list|do i have|missing|pending)\b.*\b(checkins?|check-ins?|standups?|stand-ups?)\b", + re.IGNORECASE, +) + +_KUDOS_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile( + r"^(?:please\s+)?(?:give|send)\s+kudos\s+to\s+(?P.+?)(?:\s+(?:for|because|about)\s+(?P.+))?$", + re.IGNORECASE, + ), + re.compile( + r"^(?:please\s+)?(?:kudos|shout\s*out|shoutout|props|congratulate|thank|thanks)\s+(?:to\s+)?(?P.+?)(?:\s+(?:for|because|about)\s+(?P.+))?$", + re.IGNORECASE, + ), +) + + +@dataclass(frozen=True) +class TerminalCheckinIntent: + """Parsed check-in request that should stay inside the terminal UI.""" + + action: str + + +@dataclass(frozen=True) +class KudosIntent: + """Parsed kudos request from terminal chat text.""" + + receiver_query: str + message: str + receiver_kind: str = "auto" + + +@dataclass(frozen=True) +class TerminalActionIntent: + """Parsed terminal-native action that should bypass general chat.""" + + action: str + args: tuple[str, ...] = () + + +def is_checkins_intent(raw_value: str) -> bool: + """Return True when the user is asking for the native check-ins flow.""" + value: str = _normalize(raw_value) + return value in CHECKINS_INTENT_PHRASES or bool(_CHECKINS_QUESTION_RE.search(value)) + + +def parse_terminal_checkin_intent(raw_value: str) -> TerminalCheckinIntent | None: + """Parse check-in commands that chatbot platforms expose without a slash.""" + value: str = _normalize(raw_value).lstrip("/") + if not value.startswith(("checkin", "check-in", "checkins", "check-ins")): + return None + + parts: list[str] = value.split() + if len(parts) == 1: + return TerminalCheckinIntent(action="complete") + + action: str = parts[1] + if action in {"list", "show", "complete", "start", "submit"}: + return TerminalCheckinIntent(action="complete") + if action in {"edit", "reset", "delete"}: + return TerminalCheckinIntent(action=action) + return None + + +def parse_kudos_intent(raw_value: str) -> KudosIntent | None: + """Parse simple kudos requests that should use the native CLI flow.""" + value: str = raw_value.strip() + if not value: + return None + for pattern in _KUDOS_PATTERNS: + match = pattern.match(value) + if match is None: + continue + receiver_query: str = _clean_receiver(match.group("receiver") or "") + if not receiver_query: + return None + message: str = (match.group("message") or "").strip(" .") + receiver_kind: str = "team" if _looks_like_team_receiver(receiver_query) else "auto" + receiver_query = _clean_team_receiver(receiver_query) + return KudosIntent( + receiver_query=receiver_query, + message=message, + receiver_kind=receiver_kind, + ) + return None + + +def parse_terminal_action_intent(raw_value: str) -> TerminalActionIntent | None: + """Parse simple natural language requests for terminal-native flows.""" + value: str = _normalize(raw_value).lstrip("/") + if value in {"forms", "form list", "list forms", "show forms", "my forms"}: + return TerminalActionIntent(action="forms") + if value.startswith(("submit form", "fill form", "fill out form")): + return TerminalActionIntent(action="form_submit") + if value.startswith(("form responses", "show form responses", "list form responses")): + return TerminalActionIntent(action="form_responses") + if value.startswith(("update form", "edit form response")): + return TerminalActionIntent(action="form_update") + if value.startswith(("transition form", "move form response")): + return TerminalActionIntent(action="form_transition") + if value.startswith(("delete form", "delete form response")): + return TerminalActionIntent(action="form_delete") + if value in {"users", "list users", "show users", "team members", "list members"}: + return TerminalActionIntent(action="users") + if value in {"teams", "list teams", "show teams"}: + return TerminalActionIntent(action="teams") + if value.startswith("team "): + return TerminalActionIntent(action="team", args=(value.removeprefix("team ").strip(),)) + if value in {"dashboard", "open dashboard", "dailybot dashboard"}: + return TerminalActionIntent(action="dashboard") + if value in {"mood", "track mood", "record mood", "how am i feeling"}: + return TerminalActionIntent(action="mood") + if value.startswith(("timeoff", "time off", "pto")): + return TerminalActionIntent(action="timeoff") + return None + + +def matching_users(users: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """Return active users whose name/email/handle matches the query.""" + normalized_query: str = _normalize(query).lstrip("@") + if not normalized_query: + return [] + + query_parts: list[str] = [ + part for part in re.split(r"\s+", normalized_query) if part + ] + matches: list[dict[str, Any]] = [] + for user in users: + if not user.get("is_active", True): + continue + haystack: str = _normalize( + " ".join( + str(user.get(key) or "") + for key in ( + "full_name", + "name", + "display_name", + "email", + "username", + "handle", + ) + ) + ).lstrip("@") + if normalized_query in haystack or all(part in haystack for part in query_parts): + matches.append(user) + return matches + + +def matching_teams(teams: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """Return teams whose name/UUID matches the query.""" + normalized_query: str = _normalize(query).lstrip("@") + if not normalized_query: + return [] + matches: list[dict[str, Any]] = [] + for team in teams: + haystack: str = _normalize( + " ".join(str(team.get(key) or "") for key in ("name", "uuid", "id")) + ) + if normalized_query in haystack: + matches.append(team) + return matches + + +def _normalize(value: str) -> str: + return " ".join(value.strip().lower().split()) + + +def _clean_receiver(value: str) -> str: + receiver: str = value.strip(" .") + receiver = re.sub(r"^(?:@dailybot\s+)?", "", receiver, flags=re.IGNORECASE).strip() + return receiver + + +def _looks_like_team_receiver(value: str) -> bool: + return bool(re.search(r"\b(team|squad|group|department)\b", value, re.IGNORECASE)) + + +def _clean_team_receiver(value: str) -> str: + return re.sub(r"\b(team|squad|group|department)\b", "", value, flags=re.IGNORECASE).strip() diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 40712f4..19d7668 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -163,6 +163,133 @@ def test_complete_checkin(self, client: DailyBotClient) -> None: assert "Bearer test-token" in call_kwargs["headers"]["Authorization"] assert result["uuid"] == "response-uuid" + def test_list_checkins(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": [{"id": "followup-uuid", "name": "Daily Standup"}], + "next": None, + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_checkins() + + assert result[0]["id"] == "followup-uuid" + assert mock_get.call_args[0][0].endswith("/v1/checkins/") + + def test_get_template_with_followup_context(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "template-uuid", "questions": {"fields": []}} + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: dict[str, Any] = client.get_template( + "template-uuid", + followup_uuid="followup-uuid", + ) + + assert result["id"] == "template-uuid" + assert mock_get.call_args[1]["params"] == { + "render_special_vars": "true", + "followup_id": "followup-uuid", + } + + def test_get_checkin(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "followup-uuid", "template": "template-uuid"} + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: dict[str, Any] = client.get_checkin("followup-uuid") + + assert result["template"] == "template-uuid" + assert mock_get.call_args[0][0].endswith("/v1/checkins/followup-uuid/") + + def test_list_checkin_responses(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": [{"uuid": "response-uuid"}], + "next": None, + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_checkin_responses( + "followup-uuid", + date_start="2026-07-01", + date_end="2026-07-01", + ) + + assert result[0]["uuid"] == "response-uuid" + assert mock_get.call_args[0][0].endswith("/v1/checkins/followup-uuid/responses/") + assert mock_get.call_args[1]["params"] == { + "date_start": "2026-07-01", + "date_end": "2026-07-01", + } + + def test_update_checkin_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "response-uuid"} + responses: list[dict[str, Any]] = [{"uuid": "q-0", "index": 0, "response": "Edited"}] + + with patch("httpx.put", return_value=mock_response) as mock_put: + result: dict[str, Any] = client.update_checkin_response( + "followup-uuid", + responses, + last_question_index=0, + ) + + assert result["uuid"] == "response-uuid" + assert mock_put.call_args[0][0].endswith("/v1/checkins/followup-uuid/responses/") + assert mock_put.call_args[1]["json"] == { + "responses": responses, + "last_question_index": 0, + } + + def test_delete_checkin_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"deleted": True} + + with patch("httpx.request", return_value=mock_response) as mock_request: + result: dict[str, Any] = client.delete_checkin_response( + "followup-uuid", + response_date="2026-07-01", + ) + + assert result["deleted"] is True + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith("/v1/checkins/followup-uuid/responses/") + assert mock_request.call_args[1]["params"] == { + "date_start": "2026-07-01", + "date_end": "2026-07-01", + } + + def test_track_mood(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"details": "The mood response has been tracked"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.track_mood(5) + + assert "tracked" in result["details"] + assert mock_post.call_args[0][0].endswith("/v1/mood/track/") + assert mock_post.call_args[1]["json"] == {"score": 5} + + def test_get_mood(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"motivation": {"score": 4}} + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: dict[str, Any] = client.get_mood("2026-07-01") + + assert result["motivation"]["score"] == 4 + assert mock_get.call_args[0][0].endswith("/v1/mood/track/") + assert mock_get.call_args[1]["params"] == {"date": "2026-07-01"} + def test_list_forms(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -309,11 +436,11 @@ def test_give_kudos(self, client: DailyBotClient) -> None: ) call_kwargs: dict[str, Any] = mock_post.call_args[1] - assert call_kwargs["json"]["user_uuid_receivers"] == ["user-uuid"] - assert "team_uuid_receivers" not in call_kwargs["json"] + assert call_kwargs["json"]["receivers"] == ["user-uuid"] + assert call_kwargs["json"]["users_receivers"] == ["user-uuid"] + assert "teams_receivers" not in call_kwargs["json"] assert call_kwargs["json"]["company_value"] == "value-uuid" assert "by_dailybot" not in call_kwargs["json"] - assert "receivers" not in call_kwargs["json"] assert result["uuid"] == "kudos-uuid" def test_give_kudos_team(self, client: DailyBotClient) -> None: @@ -329,8 +456,9 @@ def test_give_kudos_team(self, client: DailyBotClient) -> None: ) call_kwargs: dict[str, Any] = mock_post.call_args[1] - assert call_kwargs["json"]["user_uuid_receivers"] == ["user-uuid"] - assert call_kwargs["json"]["team_uuid_receivers"] == ["team-uuid"] + assert call_kwargs["json"]["receivers"] == ["user-uuid", "team-uuid"] + assert call_kwargs["json"]["users_receivers"] == ["user-uuid"] + assert call_kwargs["json"]["teams_receivers"] == ["team-uuid"] def test_list_teams(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) diff --git a/tests/tui_app_flows_test.py b/tests/tui_app_flows_test.py new file mode 100644 index 0000000..6c2b0fd --- /dev/null +++ b/tests/tui_app_flows_test.py @@ -0,0 +1,359 @@ +import asyncio +from datetime import date +from unittest.mock import AsyncMock, MagicMock + +from dailybot_cli.tui.app import DailybotChatApp +from dailybot_cli.tui.intents import KudosIntent, TerminalCheckinIntent + + +def test_kudos_flow_uses_numbered_user_selection() -> None: + client: MagicMock = MagicMock() + client.list_users.return_value = [ + {"uuid": "self-uuid", "full_name": "Me"}, + {"uuid": "peer-uuid", "full_name": "Andres Prieto"}, + ] + client.list_teams.return_value = [] + client.auth_status.return_value = {"user": {"uuid": "self-uuid"}} + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run( + app._start_kudos_flow( + KudosIntent(receiver_query="Andres", message="this amazing new feature") + ) + ) + asyncio.run(app._select_kudos_user("1")) + asyncio.run(app._set_kudos_value("")) + asyncio.run(app._confirm_kudos("1")) + + client.give_kudos.assert_called_once_with( + content="this amazing new feature", + user_uuid_receivers=["peer-uuid"], + team_uuid_receivers=None, + company_value=None, + ) + + +def test_checkins_flow_submits_answers_after_numbered_selection() -> None: + client: MagicMock = MagicMock() + client.get_status.return_value = { + "pending_checkins": [ + { + "followup_uuid": "followup-uuid", + "followup_name": "Daily Standup", + "template_questions": [ + { + "uuid": "q1", + "question": ( + "Previous plan ({previous_response_date}): " + "{previous_response_#2}" + ), + }, + {"uuid": "q2", "question": "Blocked?", "question_type": "boolean"}, + ], + } + ] + } + client.get_checkin.return_value = { + "id": "followup-uuid", + "name": "Daily Standup", + "template": "template-uuid", + } + client.get_template.return_value = { + "questions": { + "fields": [ + { + "uuid": "q1", + "question": "Previous plan (Jun 30): Continue with the CLI", + }, + {"uuid": "q2", "question": "Blocked?", "question_type": "boolean"}, + ] + } + } + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run(app._start_checkins_flow()) + asyncio.run(app._select_checkin("1")) + asyncio.run(app._answer_checkin_question("Built the CLI flow")) + asyncio.run(app._answer_checkin_question("2")) + + client.complete_checkin.assert_called_once_with( + "followup-uuid", + [ + {"uuid": "q1", "index": 0, "response": "Built the CLI flow"}, + {"uuid": "q2", "index": 1, "response": False}, + ], + 1, + ) + question_prompt: str = app._write_dailybot.call_args_list[1].args[0] + assert "Continue with the CLI" in question_prompt + assert "{previous_response" not in question_prompt + + +def test_checkin_edit_flow_keeps_and_replaces_answers() -> None: + client: MagicMock = MagicMock() + client.list_checkins.return_value = [{"id": "followup-uuid", "name": "Daily Standup"}] + client.list_checkin_responses.return_value = [ + { + "uuid": "daily-uuid", + "responses": [ + {"question": "What did you do?", "response": {"value": "Old work"}}, + {"question": "Blocked?", "response": True}, + ], + } + ] + client.get_checkin.return_value = { + "id": "followup-uuid", + "name": "Daily Standup", + "template": "template-uuid", + } + client.get_template.return_value = { + "questions": { + "fields": [ + {"uuid": "q1", "question": "What did you do?"}, + {"uuid": "q2", "question": "Blocked?", "question_type": "boolean"}, + ] + } + } + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run(app._handle_terminal_checkin_intent(TerminalCheckinIntent(action="edit"))) + asyncio.run(app._select_checkin_to_edit("1")) + asyncio.run(app._answer_checkin_edit_question("")) + asyncio.run(app._answer_checkin_edit_question("2")) + + question_prompt: str = app._write_dailybot.call_args_list[1].args[0] + assert "Current answer: Old work" in question_prompt + assert "Current answer: {}" not in question_prompt + client.update_checkin_response.assert_called_once_with( + "followup-uuid", + [ + {"uuid": "q1", "index": 0, "response": {"value": "Old work"}}, + {"uuid": "q2", "index": 1, "response": False}, + ], + 1, + ) + + +def test_form_submit_flow_collects_answers() -> None: + client: MagicMock = MagicMock() + client.list_forms.return_value = [ + { + "id": "form-uuid", + "name": "Code Release", + "questions": [{"uuid": "q1", "question": "What shipped?"}], + } + ] + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run(app._start_forms_flow("submit")) + asyncio.run(app._select_form_action("1")) + asyncio.run(app._answer_form_submit_question("Interactive CLI")) + + client.submit_form_response.assert_called_once_with( + "form-uuid", + {"q1": "Interactive CLI"}, + ) + + +def test_form_update_flow_keeps_existing_answers() -> None: + client: MagicMock = MagicMock() + form: dict[str, object] = { + "id": "form-uuid", + "name": "Code Release", + "questions": [{"uuid": "q1", "question": "What shipped?"}], + } + response: dict[str, object] = { + "id": "response-uuid", + "content": {"q1": {"value": "Old summary"}}, + } + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run(app._start_form_update_flow(form, response)) + asyncio.run(app._answer_form_update_question("")) + + client.update_form_response.assert_called_once_with( + "form-uuid", + "response-uuid", + {"q1": {"value": "Old summary"}}, + ) + + +def test_form_transition_flow_uses_allowed_transition() -> None: + client: MagicMock = MagicMock() + form: dict[str, object] = {"id": "form-uuid", "name": "Code Release"} + response: dict[str, object] = { + "id": "response-uuid", + "allowed_transitions": [{"to_state": "qa", "label": "QA"}], + } + app = DailybotChatApp(client) + _stub_output(app) + + app._start_form_transition_flow(form, response) + asyncio.run(app._select_form_transition("1")) + asyncio.run(app._set_form_transition_note("Ready for QA")) + + client.transition_form_response.assert_called_once_with( + "form-uuid", + "response-uuid", + "qa", + "Ready for QA", + ) + + +def test_form_delete_flow_confirms_before_delete() -> None: + client: MagicMock = MagicMock() + form: dict[str, object] = {"id": "form-uuid", "name": "Code Release"} + response: dict[str, object] = {"id": "response-uuid"} + app = DailybotChatApp(client) + _stub_output(app) + + app._start_form_delete_flow(form, response) + asyncio.run(app._confirm_form_delete("1")) + + client.delete_form_response.assert_called_once_with("form-uuid", "response-uuid") + + +def test_checkin_reset_flow_confirms_before_delete() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + _stub_output(app) + app.pending_flow = { + "type": "checkin_reset_confirm", + "candidate": {"checkin": {"id": "followup-uuid", "name": "Daily Standup"}}, + } + + asyncio.run(app._confirm_checkin_reset("1")) + + client.delete_checkin_response.assert_called_once_with( + "followup-uuid", + response_date=date.today().isoformat(), + ) + + +def test_mood_flow_tracks_score() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + _stub_output(app) + + asyncio.run(app._start_mood_flow()) + asyncio.run(app._select_mood("5")) + + client.track_mood.assert_called_once_with(5) + + +def test_structured_terminal_action_launches_native_flow() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + _stub_output(app) + app._start_kudos_menu = AsyncMock() # type: ignore[method-assign] + + asyncio.run(app._handle_chat_actions([{"type": "terminal_flow", "flow": "kudos"}])) + asyncio.run(app._confirm_terminal_action("1")) + + app._start_kudos_menu.assert_called_once() + + +def test_slash_tab_completion_cycles_matching_commands() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + completed = app._command_completion("/fo", 3) + + assert completed is not None + assert completed[0] == "/forms" + prompt = _FakePrompt(value=completed[0], cursor_position=completed[1]) + app._cycle_completion(prompt) # type: ignore[arg-type] + assert prompt.value == "/form submit" + + +def test_slash_shift_tab_completion_cycles_backwards() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + completed = app._command_completion("/fo", 3) + + assert completed is not None + prompt = _FakePrompt(value=completed[0], cursor_position=completed[1]) + app._cycle_completion(prompt, reverse=True) # type: ignore[arg-type] + assert prompt.value == "/form delete" + + +def test_slash_shift_tab_completion_starts_from_last_match() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + completed = app._command_completion("/fo", 3, reverse=True) + + assert completed is not None + assert completed[0] == "/form delete" + + +def test_mention_tab_completion_supports_users_and_teams() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + user_completed = app._mention_completion( + "kudos @an", + len("kudos @an"), + ["@Andres Prieto", "@Engineering team"], + ) + team_completed = app._mention_completion( + "kudos @eng", + len("kudos @eng"), + ["@Andres Prieto", "@Engineering team"], + ) + + assert user_completed is not None + assert user_completed[0] == "kudos @Andres Prieto" + assert team_completed is not None + assert team_completed[0] == "kudos @Engineering team" + + +def test_mention_tab_completion_cycles_matches() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + completed = app._mention_completion( + "kudos @and", + len("kudos @and"), + ["@Andres Prieto", "@Andrea Dailybot"], + ) + + assert completed is not None + prompt = _FakePrompt(value=completed[0], cursor_position=completed[1]) + app._cycle_completion(prompt) # type: ignore[arg-type] + assert prompt.value == "kudos @Andrea Dailybot" + + +def test_mention_shift_tab_completion_cycles_backwards() -> None: + client: MagicMock = MagicMock() + app = DailybotChatApp(client) + + completed = app._mention_completion( + "kudos @and", + len("kudos @and"), + ["@Andres Prieto", "@Andrea Dailybot"], + ) + + assert completed is not None + prompt = _FakePrompt(value=completed[0], cursor_position=completed[1]) + app._cycle_completion(prompt, reverse=True) # type: ignore[arg-type] + assert prompt.value == "kudos @Andrea Dailybot" + + +def _stub_output(app: DailybotChatApp) -> None: + app._write_dailybot = MagicMock() # type: ignore[method-assign] + app._write_error = MagicMock() # type: ignore[method-assign] + app._set_loading = MagicMock() # type: ignore[method-assign] + app._set_prompt_hint = MagicMock() # type: ignore[method-assign] + + +class _FakePrompt: + def __init__(self, *, value: str, cursor_position: int) -> None: + self.value = value + self.cursor_position = cursor_position diff --git a/tests/tui_intents_test.py b/tests/tui_intents_test.py new file mode 100644 index 0000000..e38ef13 --- /dev/null +++ b/tests/tui_intents_test.py @@ -0,0 +1,75 @@ +from dailybot_cli.tui.intents import ( + is_checkins_intent, + matching_teams, + matching_users, + parse_kudos_intent, + parse_terminal_action_intent, + parse_terminal_checkin_intent, +) + + +def test_checkins_plain_text_routes_to_native_flow() -> None: + assert is_checkins_intent("checkins") + assert is_checkins_intent("show my check-ins") + assert is_checkins_intent("what checkins do I have pending?") + + +def test_parse_kudos_intent_with_message() -> None: + intent = parse_kudos_intent("give kudos to Andres for this amazing new feature") + + assert intent is not None + assert intent.receiver_query == "Andres" + assert intent.message == "this amazing new feature" + assert intent.receiver_kind == "auto" + + +def test_parse_kudos_intent_without_message() -> None: + intent = parse_kudos_intent("send kudos to Jane Doe") + + assert intent is not None + assert intent.receiver_query == "Jane Doe" + assert intent.message == "" + + +def test_parse_kudos_intent_detects_team_receiver() -> None: + intent = parse_kudos_intent("give kudos to Engineering team for shipping") + + assert intent is not None + assert intent.receiver_query == "Engineering" + assert intent.receiver_kind == "team" + + +def test_matching_users_filters_by_name_and_email() -> None: + users = [ + {"uuid": "1", "full_name": "Andres Prieto", "email": "andres@example.com"}, + {"uuid": "2", "full_name": "Jane Doe", "email": "jane@example.com"}, + ] + + assert matching_users(users, "andres") == [users[0]] + assert matching_users(users, "jane@example.com") == [users[1]] + + +def test_matching_teams_filters_by_name() -> None: + teams = [ + {"uuid": "1", "name": "Engineering"}, + {"uuid": "2", "name": "Customer Success"}, + ] + + assert matching_teams(teams, "engineer") == [teams[0]] + + +def test_parse_terminal_checkin_intent_accepts_slash_aliases() -> None: + assert parse_terminal_checkin_intent("/checkin").action == "complete" + assert parse_terminal_checkin_intent("/checkin list").action == "complete" + assert parse_terminal_checkin_intent("/checkin complete").action == "complete" + assert parse_terminal_checkin_intent("/checkin edit").action == "edit" + assert parse_terminal_checkin_intent("/checkin reset").action == "reset" + + +def test_parse_terminal_action_intent_routes_common_actions() -> None: + assert parse_terminal_action_intent("forms").action == "forms" + assert parse_terminal_action_intent("submit form").action == "form_submit" + assert parse_terminal_action_intent("list users").action == "users" + assert parse_terminal_action_intent("show teams").action == "teams" + assert parse_terminal_action_intent("open dashboard").action == "dashboard" + assert parse_terminal_action_intent("track mood").action == "mood" From 776bd9d9fce88441b7550cb328be2bded1960f23 Mon Sep 17 00:00:00 2001 From: AndresMpa Date: Thu, 2 Jul 2026 13:28:57 +0000 Subject: [PATCH 02/12] fix(interactive): satisfy ruff format for terminal flows Co-authored-by: Cursor --- dailybot_cli/tui/app.py | 71 ++++++++++++++++++++++++------------ dailybot_cli/tui/commands.py | 10 ++++- dailybot_cli/tui/intents.py | 4 +- tests/tui_app_flows_test.py | 3 +- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/dailybot_cli/tui/app.py b/dailybot_cli/tui/app.py index f73b3d4..a52d478 100644 --- a/dailybot_cli/tui/app.py +++ b/dailybot_cli/tui/app.py @@ -550,7 +550,9 @@ def _load_checkin_edit_context(self, selected: dict[str, Any]) -> dict[str, Any] return {"checkin": checkin_detail, "questions": [], "existing_responses": []} template = self.client.get_template(template_uuid, followup_uuid=followup_uuid) questions: list[dict[str, Any]] = (template.get("questions") or {}).get("fields") or [] - existing_responses: list[dict[str, Any]] = selected.get("response", {}).get("responses") or [] + existing_responses: list[dict[str, Any]] = ( + selected.get("response", {}).get("responses") or [] + ) return { "checkin": checkin_detail, "questions": questions, @@ -592,7 +594,9 @@ def _load_checkin_questions(self, checkin: dict[str, Any]) -> list[dict[str, Any if not template_uuid: return checkin.get("template_questions") or [] template = self.client.get_template(template_uuid, followup_uuid=followup_uuid) - rendered_questions: list[dict[str, Any]] = (template.get("questions") or {}).get("fields") or [] + rendered_questions: list[dict[str, Any]] = (template.get("questions") or {}).get( + "fields" + ) or [] return rendered_questions or checkin.get("template_questions") or [] async def _answer_checkin_question(self, raw_value: str) -> None: @@ -693,7 +697,9 @@ async def _start_kudos_menu(self) -> None: "teams": teams, "current_uuid": current_uuid, } - self._write_dailybot("Who should receive kudos?\n1. One or more users\n2. One or more teams") + self._write_dailybot( + "Who should receive kudos?\n1. One or more users\n2. One or more teams" + ) self._set_prompt_hint("Type 1 or 2, or `cancel`.") async def _start_kudos_flow(self, intent: KudosIntent) -> None: @@ -872,7 +878,9 @@ def _ask_kudos_value( "message": message, "current_uuid": current_uuid, } - self._write_dailybot("Optional: type a company value for this kudos, or press Enter to skip.") + self._write_dailybot( + "Optional: type a company value for this kudos, or press Enter to skip." + ) self._set_prompt_hint("Type a value, press Enter to skip, or `cancel`.") async def _set_kudos_value(self, raw_value: str) -> None: @@ -898,7 +906,11 @@ async def _confirm_kudos(self, raw_value: str) -> None: message: str = str(self.pending_flow.get("message") or "") company_value: str | None = self.pending_flow.get("company_value") user_uuids: list[str] = [str(user.get("uuid") or "") for user in users if user.get("uuid")] - team_uuids: list[str] = [str(team.get("uuid") or team.get("id") or "") for team in teams if team.get("uuid") or team.get("id")] + team_uuids: list[str] = [ + str(team.get("uuid") or team.get("id") or "") + for team in teams + if team.get("uuid") or team.get("id") + ] self.pending_flow = None self._set_prompt_hint() self._set_loading(True) @@ -1164,7 +1176,9 @@ async def _answer_form_update_question(self, raw_value: str) -> None: index: int = int(self.pending_flow["index"]) question: dict[str, Any] = questions[index] existing_value: Any = self._existing_form_response_value(question) - answer: Any = existing_value if raw_value == "" else self._parse_question_answer(raw_value, question) + answer: Any = ( + existing_value if raw_value == "" else self._parse_question_answer(raw_value, question) + ) if answer is None: return self.pending_flow["answers"].append(answer) @@ -1312,7 +1326,9 @@ def _ask_current_form_question(self) -> None: questions: list[dict[str, Any]] = self.pending_flow["questions"] index: int = int(self.pending_flow["index"]) question: dict[str, Any] = questions[index] - lines: list[str] = [f"Question {index + 1}/{len(questions)}: {self._question_label(question, index)}"] + lines: list[str] = [ + f"Question {index + 1}/{len(questions)}: {self._question_label(question, index)}" + ] choices: list[str] = self._question_choices(question) if self._question_type(question) == "boolean": lines.extend(["1. Yes", "2. No"]) @@ -1350,7 +1366,9 @@ def _ask_current_form_update_question(self) -> None: def _existing_form_response_value(self, question: dict[str, Any]) -> Any: assert self.pending_flow is not None response: dict[str, Any] = self.pending_flow.get("response") or {} - content: dict[str, Any] = response.get("content") if isinstance(response.get("content"), dict) else {} + content: dict[str, Any] = ( + response.get("content") if isinstance(response.get("content"), dict) else {} + ) question_uuid: str = str(question.get("uuid") or question.get("id") or "") return content.get(question_uuid, "") @@ -1378,7 +1396,16 @@ def _build_form_content( def _form_label(cls, form: dict[str, Any]) -> str: questions_count: int = len(cls._form_questions(form)) suffix: str = f" ({questions_count} question{'s' if questions_count != 1 else ''})" - return str(form.get("name") or form.get("title") or form.get("uuid") or form.get("id") or "Form") + suffix + return ( + str( + form.get("name") + or form.get("title") + or form.get("uuid") + or form.get("id") + or "Form" + ) + + suffix + ) @classmethod def _form_response_label(cls, response: dict[str, Any]) -> str: @@ -1513,12 +1540,7 @@ def _show_dashboard(self) -> None: async def _start_mood_flow(self) -> None: self.pending_flow = {"type": "mood_select"} self._write_dailybot( - "How is your mood today?\n" - "1. Very low\n" - "2. Low\n" - "3. Okay\n" - "4. Good\n" - "5. Great" + "How is your mood today?\n1. Very low\n2. Low\n3. Okay\n4. Good\n5. Great" ) self._set_prompt_hint("Type a number from 1 to 5, or `cancel`.") @@ -1604,7 +1626,9 @@ async def _confirm_checkin_reset(self, raw_value: str) -> None: return finally: self._set_loading(False) - self._write_dailybot(f"Done — deleted today's response for **{self._checkin_name(checkin)}**.") + self._write_dailybot( + f"Done — deleted today's response for **{self._checkin_name(checkin)}**." + ) async def _handle_terminal_action_intent(self, intent: TerminalActionIntent) -> None: await self._start_terminal_flow_by_name(intent.action, {"args": list(intent.args)}) @@ -1619,9 +1643,7 @@ async def _handle_chat_actions(self, actions: list[dict[str, Any]]) -> None: "flow": flow_name, "params": action.get("params") or {}, } - self._write_dailybot( - f"Start the **{flow_name}** terminal flow?\n1. Yes\n2. No" - ) + self._write_dailybot(f"Start the **{flow_name}** terminal flow?\n1. Yes\n2. No") self._set_prompt_hint("Type 1 to start, 2 to skip.") return action_names: str = ", ".join(str(action.get("name", "action")) for action in actions) @@ -1657,7 +1679,9 @@ async def _start_terminal_flow_by_name(self, flow_name: str, params: dict[str, A receiver_query: str = str(params.get("receiver_query") or params.get("receivers") or "") message: str = str(params.get("message") or "") if receiver_query: - await self._start_kudos_flow(KudosIntent(receiver_query=receiver_query, message=message)) + await self._start_kudos_flow( + KudosIntent(receiver_query=receiver_query, message=message) + ) else: await self._start_kudos_menu() return @@ -2168,10 +2192,9 @@ async def _load_mention_completion_items(self) -> list[str]: def _can_cycle_completion(self, value: str, cursor_position: int) -> bool: if self.completion_state is None: return False - return ( - value == self.completion_state.get("last_value") - and cursor_position == self.completion_state.get("last_cursor") - ) + return value == self.completion_state.get( + "last_value" + ) and cursor_position == self.completion_state.get("last_cursor") def _cycle_completion(self, prompt: Input, *, reverse: bool = False) -> None: assert self.completion_state is not None diff --git a/dailybot_cli/tui/commands.py b/dailybot_cli/tui/commands.py index 231f384..4da038d 100644 --- a/dailybot_cli/tui/commands.py +++ b/dailybot_cli/tui/commands.py @@ -70,13 +70,19 @@ {"name": "/dashboard", "description": "Show the Dailybot dashboard URL."}, {"name": "/checkins", "description": "Complete pending check-ins with numbered prompts."}, {"name": "/checkin edit", "description": "Edit today's submitted check-in answers."}, - {"name": "/checkin reset", "description": "Delete today's submitted check-in response after confirmation."}, + { + "name": "/checkin reset", + "description": "Delete today's submitted check-in response after confirmation.", + }, {"name": "/kudos", "description": "Send kudos to users or teams from the terminal."}, {"name": "/forms", "description": "List forms and submit or manage responses."}, {"name": "/form submit", "description": "Submit a form response question by question."}, {"name": "/form responses", "description": "Browse form responses visible to the user."}, {"name": "/form update", "description": "Update a form response question by question."}, - {"name": "/form transition", "description": "Move a workflow form response to an allowed state."}, + { + "name": "/form transition", + "description": "Move a workflow form response to an allowed state.", + }, {"name": "/form delete", "description": "Delete a form response after confirmation."}, {"name": "/users", "description": "Browse organization members."}, {"name": "/teams", "description": "Browse teams."}, diff --git a/dailybot_cli/tui/intents.py b/dailybot_cli/tui/intents.py index 6170a06..ff7751b 100644 --- a/dailybot_cli/tui/intents.py +++ b/dailybot_cli/tui/intents.py @@ -147,9 +147,7 @@ def matching_users(users: list[dict[str, Any]], query: str) -> list[dict[str, An if not normalized_query: return [] - query_parts: list[str] = [ - part for part in re.split(r"\s+", normalized_query) if part - ] + query_parts: list[str] = [part for part in re.split(r"\s+", normalized_query) if part] matches: list[dict[str, Any]] = [] for user in users: if not user.get("is_active", True): diff --git a/tests/tui_app_flows_test.py b/tests/tui_app_flows_test.py index 6c2b0fd..3a0f171 100644 --- a/tests/tui_app_flows_test.py +++ b/tests/tui_app_flows_test.py @@ -45,8 +45,7 @@ def test_checkins_flow_submits_answers_after_numbered_selection() -> None: { "uuid": "q1", "question": ( - "Previous plan ({previous_response_date}): " - "{previous_response_#2}" + "Previous plan ({previous_response_date}): {previous_response_#2}" ), }, {"uuid": "q2", "question": "Blocked?", "question_type": "boolean"}, From e27cfc7a1256ffe935eec38dfe13a64186ce2fe8 Mon Sep 17 00:00:00 2001 From: AndresMpa Date: Thu, 2 Jul 2026 13:42:11 +0000 Subject: [PATCH 03/12] fix(interactive): satisfy mypy for form responses Co-authored-by: Cursor --- dailybot_cli/tui/app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dailybot_cli/tui/app.py b/dailybot_cli/tui/app.py index a52d478..521d2c6 100644 --- a/dailybot_cli/tui/app.py +++ b/dailybot_cli/tui/app.py @@ -1366,9 +1366,8 @@ def _ask_current_form_update_question(self) -> None: def _existing_form_response_value(self, question: dict[str, Any]) -> Any: assert self.pending_flow is not None response: dict[str, Any] = self.pending_flow.get("response") or {} - content: dict[str, Any] = ( - response.get("content") if isinstance(response.get("content"), dict) else {} - ) + raw_content: Any = response.get("content") + content: dict[str, Any] = raw_content if isinstance(raw_content, dict) else {} question_uuid: str = str(question.get("uuid") or question.get("id") or "") return content.get(question_uuid, "") From a928af78afab63b959aa584c70b937d837d8fa1a Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 01:18:59 +0000 Subject: [PATCH 04/12] fix(auth): accept API key on user-scoped and CLI-personal commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary User-scoped commands (checkin, form, kudos, team, user) and the CLI-personal commands (status, update) now authenticate with an org API key, not just a login session, so headless/CI usage works with only DAILYBOT_API_KEY. ## Change Log - _headers() falls back to X-API-KEY when no Bearer token is present - Renamed require_bearer_auth() -> require_auth(); resolves via get_agent_auth() - status.py / update.py gates now accept either credential (get_agent_auth) - get_current_user_uuid() degrades to None under API-key auth (unblocks kudos give) - Dropped Bearer-only caveats across docs/ and the vendored skill pack - Added API-key parity tests for headers, status, update, and get_current_user_uuid ## Risks - None for Bearer users — Bearer stays preferred; API key is a fallback only Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/dailybot/checkin/SKILL.md | 26 +- .agents/skills/dailybot/forms/SKILL.md | 10 +- .agents/skills/dailybot/kudos/SKILL.md | 10 +- .agents/skills/dailybot/shared/auth.md | 51 ++-- .agents/skills/dailybot/teams/SKILL.md | 10 +- AGENTS.md | 2 +- dailybot_cli/api_client.py | 16 +- dailybot_cli/commands/checkin.py | 6 +- dailybot_cli/commands/form.py | 18 +- dailybot_cli/commands/kudos.py | 4 +- dailybot_cli/commands/public_api_helpers.py | 29 ++- dailybot_cli/commands/status.py | 9 +- dailybot_cli/commands/team.py | 6 +- dailybot_cli/commands/update.py | 13 +- dailybot_cli/commands/user.py | 4 +- docs/API_REFERENCE.md | 38 ++- docs/ARCHITECTURE.md | 11 +- docs/CLI_COMMAND_BEST_PRACTICES.md | 8 +- docs/CONFIGURATION.md | 14 +- docs/ECOSYSTEM_CONTEXT.md | 18 +- docs/PRODUCT_SPEC.md | 2 +- docs/TESTING_GUIDE.md | 2 +- tests/api_client_test.py | 23 ++ tests/commands_test.py | 70 +++-- tests/public_api_commands_test.py | 271 +++++++++++--------- 25 files changed, 411 insertions(+), 260 deletions(-) diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 354e733..072b6e7 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -16,14 +16,15 @@ You help developers complete their pending check-ins (daily standups, weekly sur ## Auth model — user-scoped commands -Check-in commands require a **Bearer token** (user session), not an API key. -The developer must be logged in via `dailybot login`. This is the same -session used by the webapp — it scopes actions to the logged-in human's -permissions and pending check-ins. +Check-in commands accept **either** a Bearer login session (`dailybot login`) +**or** an org API key (`DAILYBOT_API_KEY`). The server honours `X-API-KEY` on +`/v1/cli/status/` (pending check-ins) and `/v1/checkins//responses/` +(completion), resolving the acting user from the key's owner — identical scope +to the Bearer path (the logged-in human's permissions and pending check-ins). -If the developer only has an API key (`DAILYBOT_API_KEY`), guide them through -`dailybot login` first. API keys authenticate agent-scoped endpoints -(`dailybot agent ...`), not user-scoped ones. +If the developer has only an API key, check-in commands still work — the CLI +falls back to `X-API-KEY`. Prefer `dailybot login` when the developer wants +their own personal, human-scoped pending check-ins. --- @@ -43,15 +44,16 @@ specific questions; reports are freeform updates. Read and follow the authentication steps in [`../shared/auth.md`](../shared/auth.md). That file covers CLI installation, login, API key setup, and agent profile configuration. -**Additionally**, verify the developer has a user session (Bearer token): +**Additionally**, confirm at least one credential is present (a Bearer login +session or an API key): ```bash dailybot status --auth 2>&1 ``` -If the output shows a logged-in user session, proceed. If not, guide them -through `dailybot login` (see auth.md for the OTP flow). Check-in commands -will not work with only an API key. +If the output shows a logged-in user session **or** a configured API key, +proceed. Otherwise guide them through `dailybot login` (see auth.md for the +OTP flow) or ask them to set `DAILYBOT_API_KEY`. If auth fails or the developer declines, skip and continue with your primary task. @@ -170,7 +172,7 @@ dailybot checkin complete \ See [`../shared/http-fallback.md`](../shared/http-fallback.md) for base patterns. -**Important:** Check-in endpoints use **Bearer token** auth, not API key auth. +**Important:** Check-in endpoints accept **either** Bearer token or `X-API-KEY` auth. ### List pending check-ins diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 1cc2ac2..6f804db 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -35,9 +35,9 @@ Customer-authored form skills live at `.agents/skills/dailybot-custom//SKI ## Auth model — user-scoped commands -All form commands require a **Bearer token** (user session), not an API key. The developer must be logged in via `dailybot login`. This scopes form access to the logged-in human's permissions — they only see forms (and responses) they have access to, and the server enforces every audience check on the API side. +All form commands accept **either** a Bearer login session (`dailybot login`) **or** an org API key (`DAILYBOT_API_KEY`). Access is scoped to the acting identity's permissions — they only see forms (and responses) they have access to, and the server enforces every audience check on the API side. -If the developer only has an API key (`DAILYBOT_API_KEY`), guide them through `dailybot login` first. API keys authenticate agent-scoped endpoints (`dailybot agent ...`), not user-scoped ones. +If the developer has only an API key, form commands still work — the CLI falls back to `X-API-KEY`. Prefer `dailybot login` when the developer wants a personal, human-scoped view. --- @@ -58,13 +58,13 @@ Do **not** use this skill for daily standup check-ins — route those to `dailyb Read and follow the authentication steps in [`../shared/auth.md`](../shared/auth.md). That file covers CLI installation, login, API key setup, and agent profile configuration. -**Additionally**, verify the developer has a user session (Bearer token): +**Additionally**, confirm at least one credential is present (a Bearer login session or an API key): ```bash dailybot status --auth 2>&1 ``` -If the output shows a logged-in user session, proceed. If not, guide them through `dailybot login` (see auth.md for the OTP flow). Form commands will not work with only an API key. +If the output shows a logged-in user session **or** a configured API key, proceed. Otherwise guide them through `dailybot login` (see auth.md for the OTP flow) or ask them to set `DAILYBOT_API_KEY`. A scripted preflight that does both at once and exits with code `3` if unauthenticated: @@ -588,7 +588,7 @@ If the server returns a body without a `code` (network error, gateway error, mal See [`../shared/http-fallback.md`](../shared/http-fallback.md) for base patterns. -**Important:** Form endpoints use **Bearer token** auth, not API key auth. +**Important:** Form endpoints accept **either** Bearer token or `X-API-KEY` auth. ### List forms (with questions) diff --git a/.agents/skills/dailybot/kudos/SKILL.md b/.agents/skills/dailybot/kudos/SKILL.md index a3e3826..8ad9b93 100644 --- a/.agents/skills/dailybot/kudos/SKILL.md +++ b/.agents/skills/dailybot/kudos/SKILL.md @@ -23,9 +23,9 @@ Two recipient types are supported: ## Auth model — user-scoped commands -Kudos commands require a **Bearer token** (user session), not an API key. The developer must be logged in via `dailybot login`. This scopes kudos to the logged-in human — the kudos appear as coming from them, not from an agent. +Kudos commands accept **either** a Bearer login session (`dailybot login`) **or** an org API key (`DAILYBOT_API_KEY`). Kudos are scoped to the acting identity — the server resolves the API key's owner, so they appear as coming from that user. -If the developer only has an API key (`DAILYBOT_API_KEY`), guide them through `dailybot login` first. API keys authenticate agent-scoped endpoints (`dailybot agent ...`), not user-scoped ones. +If the developer has only an API key, kudos still work — the CLI falls back to `X-API-KEY`. Prefer `dailybot login` when the developer wants the kudos attributed to their own human account. --- @@ -53,13 +53,13 @@ Do **not** send kudos autonomously without the developer's explicit request. Kud Read and follow the authentication steps in [`../shared/auth.md`](../shared/auth.md). That file covers CLI installation, login, API key setup, and agent profile configuration. -**Additionally**, verify the developer has a user session (Bearer token): +**Additionally**, confirm at least one credential is present (a Bearer login session or an API key): ```bash dailybot status --auth 2>&1 ``` -If the output shows a logged-in user session, proceed. If not, guide them through `dailybot login` (see auth.md for the OTP flow). +If the output shows a logged-in user session **or** a configured API key, proceed. Otherwise guide them through `dailybot login` (see auth.md for the OTP flow) or ask them to set `DAILYBOT_API_KEY`. If auth fails or the developer declines, skip and continue with your primary task. @@ -292,7 +292,7 @@ Agent: See [`../shared/http-fallback.md`](../shared/http-fallback.md) for base patterns. -**Important:** Kudos endpoints use **Bearer token** auth, not API key auth. +**Important:** Kudos endpoints accept **either** Bearer token or `X-API-KEY` auth. ### List teams (to resolve team names) diff --git a/.agents/skills/dailybot/shared/auth.md b/.agents/skills/dailybot/shared/auth.md index bf90e8e..2ed3d07 100644 --- a/.agents/skills/dailybot/shared/auth.md +++ b/.agents/skills/dailybot/shared/auth.md @@ -303,33 +303,38 @@ Dailybot → Settings → API Keys. --- -## 4. User-Scoped Commands (Bearer Token Auth) +## 4. User-Scoped Commands (API Key *or* Bearer Token) -Some Dailybot features — **check-ins**, **forms**, **kudos**, and **user -directory** — are scoped to the logged-in human's session, not to an agent -identity. These commands use a **Bearer token** stored at -`~/.config/dailybot/credentials.json` after `dailybot login`. +Almost all Dailybot CLI commands — **check-ins**, **forms**, **kudos**, +**teams**, the **user directory**, **status**, and **update** — accept +**either** credential: a Bearer login token (stored at +`~/.config/dailybot/credentials.json` after `dailybot login`) **or** an org +API key (`DAILYBOT_API_KEY` env var or `dailybot config key=...`). The CLI +prefers the login session when present and falls back to the API key, so these +commands work in headless/CI environments with only an API key set. The server +resolves the acting user from the API key's owner, so the scope is identical to +the Bearer path. -### Auth model distinction +### Auth model -| Scope | Auth method | How to set up | Used by | -|-------|-------------|---------------|---------| -| **Agent endpoints** | API key (`X-API-KEY` header) | `dailybot config key=...` or `DAILYBOT_API_KEY` env | `dailybot agent update`, `dailybot agent health`, `dailybot agent email send` | -| **User endpoints** | Bearer token (`Authorization: Bearer `) | `dailybot login` (OTP email flow) | `dailybot checkin`, `dailybot form`, `dailybot kudos`, `dailybot user` | +| Scope | Accepted credentials | Used by | +|-------|----------------------|---------| +| **Agent endpoints** | API key (`X-API-KEY`) preferred, Bearer fallback | `dailybot agent update`, `dailybot agent health`, `dailybot agent email send` | +| **User / CLI endpoints** | Bearer token preferred, API key fallback — **either works** | `dailybot status`, `dailybot update`, `dailybot checkin`, `dailybot form`, `dailybot kudos`, `dailybot team`, `dailybot user`, `dailybot chat` | +| **Login lifecycle** | OTP / Bearer only | `dailybot login`, `dailybot logout` | -Both auth paths can coexist — the CLI stores them separately. A developer -can have both an API key (for agent operations) and a Bearer session (for -user-scoped operations) active at the same time. +Both credentials can coexist — the CLI stores them separately, and a developer +can hold an API key and a Bearer session at the same time. -### Checking user session status +### Checking session status ```bash dailybot status --auth 2>&1 ``` The output shows both the agent API key status and the Bearer session status. -If the user session is missing or expired, guide through `dailybot login` -using the OTP flow in Section 2 above. +If neither is present, guide the developer through `dailybot login` (Section 2) +or ask them to export `DAILYBOT_API_KEY`. ### Config directory override @@ -345,10 +350,12 @@ This is useful for development sandboxes, CI environments, or testing scenarios with isolated config directories. The directory is created automatically if it does not exist. -### User-scoped commands fail without a Bearer session +### Commands need *some* credential -If a developer tries to use `dailybot checkin`, `dailybot form`, -`dailybot kudos`, or `dailybot user` with only an API key and no login -session, the CLI exits with code `3` (not authenticated). Guide them -through `dailybot login` — these commands require the human's own -session, not an agent key. +`dailybot status`, `dailybot update`, `dailybot checkin`, `dailybot form`, +`dailybot kudos`, `dailybot team`, `dailybot user`, and `dailybot chat` all +work with **either** a Bearer login session or an org API key. They exit with a +non-zero "not authenticated" code only when **neither** is present — in that +case guide the developer through `dailybot login` or ask them to set +`DAILYBOT_API_KEY`. Only the login lifecycle itself (`dailybot login` / +`logout`) is inherently Bearer/OTP. diff --git a/.agents/skills/dailybot/teams/SKILL.md b/.agents/skills/dailybot/teams/SKILL.md index 26a667f..d25e619 100644 --- a/.agents/skills/dailybot/teams/SKILL.md +++ b/.agents/skills/dailybot/teams/SKILL.md @@ -20,9 +20,9 @@ This skill is primarily a **resolver dependency** for other Dailybot skills (mos ## Auth model — user-scoped commands -Team-read commands require a **Bearer token** (user session), not an API key. The developer must be logged in via `dailybot login`. This scopes results to the logged-in human's permissions — they only see teams the server allows them to see. +Team-read commands accept **either** a Bearer login session (`dailybot login`) **or** an org API key (`DAILYBOT_API_KEY`). Results are scoped to the acting identity's permissions — they only see teams the server allows them to see. -If the developer only has an API key (`DAILYBOT_API_KEY`), guide them through `dailybot login` first. API keys authenticate agent-scoped endpoints (`dailybot agent ...`), not user-scoped ones. +If the developer has only an API key, team commands still work — the CLI falls back to `X-API-KEY`. Prefer `dailybot login` when the developer wants a personal, human-scoped view. --- @@ -58,13 +58,13 @@ Do **not** use this skill to enumerate org members when the goal is to recognize Read and follow the authentication steps in [`../shared/auth.md`](../shared/auth.md). That file covers CLI installation, login, API key setup, and agent profile configuration. -**Additionally**, verify the developer has a user session (Bearer token): +**Additionally**, confirm at least one credential is present (a Bearer login session or an API key): ```bash dailybot status --auth 2>&1 ``` -If the output shows a logged-in user session, proceed. If not, guide them through `dailybot login` (see auth.md for the OTP flow). Team commands will not work with only an API key. +If the output shows a logged-in user session **or** a configured API key, proceed. Otherwise guide them through `dailybot login` (see auth.md for the OTP flow) or ask them to set `DAILYBOT_API_KEY`. If auth fails or the developer declines, skip and continue with your primary task. @@ -183,7 +183,7 @@ Concretely: a calling skill should invoke the same `dailybot team list --json` c See [`../shared/http-fallback.md`](../shared/http-fallback.md) for base patterns. -**Important:** Team endpoints use **Bearer token** auth, not API key auth. +**Important:** Team endpoints accept **either** Bearer token or `X-API-KEY` auth. ### List teams (role-scoped) diff --git a/AGENTS.md b/AGENTS.md index a3e5994..85af0fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ dailybot_cli/ # Source package ├── team.py # `team` group: list / get (server-scoped by role) ├── kudos.py # `kudos give` (to a user, a team, or both) ├── user.py # `user list` (org directory) - ├── public_api_helpers.py # require_bearer_auth, exit_for_api_error, + ├── public_api_helpers.py # require_auth, exit_for_api_error, │ # ERROR_CODE_MESSAGES, resolve_user_/team_by_name_or_uuid ├── user_scoped_actions.py # shared handlers for checkin + form (used by CLI + TUI) ├── interactive.py # questionary-based TUI when run with no args diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 5ba5089..dc2cc1f 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -36,13 +36,23 @@ def __init__( self._agent_auth_mode: str | None = None def _headers(self, authenticated: bool = True) -> dict[str, str]: - """Build request headers.""" + """Build request headers. + + Prefers the Bearer login token; falls back to the org API key so that + user-scoped endpoints (users, teams, forms, kudos, check-ins) work under + either credential. The server accepts both on these endpoints. + """ headers: dict[str, str] = { "Content-Type": "application/json", "Accept": "application/json", } - if authenticated and self.token: - headers["Authorization"] = f"Bearer {self.token}" + if authenticated: + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + self._agent_auth_mode = "bearer" + elif self.api_key: + headers["X-API-KEY"] = self.api_key + self._agent_auth_mode = "api_key" return headers def _agent_headers(self) -> dict[str, str]: diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index ae8e11a..d830490 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -2,7 +2,7 @@ import click -from dailybot_cli.commands.public_api_helpers import require_bearer_auth +from dailybot_cli.commands.public_api_helpers import require_auth from dailybot_cli.commands.user_scoped_actions import execute_checkin_complete, execute_checkin_list _HELP: str = "Acts as you. You can only see and act on what you could in the webapp." @@ -30,7 +30,7 @@ def checkin_list(json_mode: bool) -> None: dailybot checkin list dailybot checkin list --json """ - client = require_bearer_auth() + client = require_auth() execute_checkin_list(client, json_mode=json_mode) @@ -67,7 +67,7 @@ def checkin_complete( dailybot checkin complete -a 0="Shipped auth" -a 1="Reviewing migrations" dailybot checkin complete --yes """ - client = require_bearer_auth() + client = require_auth() execute_checkin_complete( client, followup_uuid, diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index d140ffb..2ac7c8a 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -9,7 +9,7 @@ confirm_write, emit_json, exit_for_api_error, - require_bearer_auth, + require_auth, ) from dailybot_cli.commands.user_scoped_actions import ( execute_form_list, @@ -58,7 +58,7 @@ def form_list(json_mode: bool) -> None: dailybot form list dailybot form list --json """ - client = require_bearer_auth() + client = require_auth() execute_form_list(client, json_mode=json_mode) @@ -76,7 +76,7 @@ def form_get(form_uuid: str, json_mode: bool) -> None: dailybot form get dailybot form get --json """ - client = require_bearer_auth() + client = require_auth() try: with console.status("Loading form..."): data: dict[str, Any] = client.get_form(form_uuid) @@ -119,7 +119,7 @@ def form_submit( dailybot form submit --content '{"":"Yes"}' dailybot form submit --content '{"":"Answer"}' --yes """ - client = require_bearer_auth() + client = require_auth() content_map = resolve_form_content(client, form_uuid, content) execute_form_submit( client, @@ -156,7 +156,7 @@ def form_responses( dailybot form responses --state qa --json dailybot form responses --latest --json """ - client = require_bearer_auth() + client = require_auth() try: with console.status("Fetching responses..."): responses: list[dict[str, Any]] = client.list_form_responses(form_uuid, state=state) @@ -191,7 +191,7 @@ def form_response_get(form_uuid: str, response_uuid: str, json_mode: bool) -> No dailybot form response get dailybot form response get --json """ - client = require_bearer_auth() + client = require_auth() try: with console.status("Loading response..."): data: dict[str, Any] = client.get_form_response(form_uuid, response_uuid) @@ -234,7 +234,7 @@ def form_update( --content '{"": "PR #4242"}' dailybot form update -c '{...}' --yes --json """ - client = require_bearer_auth() + client = require_auth() content_map: dict[str, Any] = parse_form_content_json(content) summary_lines: list[str] = [ @@ -292,7 +292,7 @@ def form_transition( dailybot form transition qa --note "QA assigned" dailybot form transition released --json """ - client = require_bearer_auth() + client = require_auth() summary_lines: list[str] = [ f"Form UUID: {form_uuid}", @@ -341,7 +341,7 @@ def form_delete( dailybot form delete dailybot form delete --yes """ - client = require_bearer_auth() + client = require_auth() summary_lines: list[str] = [ f"Form UUID: {form_uuid}", diff --git a/dailybot_cli/commands/kudos.py b/dailybot_cli/commands/kudos.py index 3eb371a..8aeecee 100644 --- a/dailybot_cli/commands/kudos.py +++ b/dailybot_cli/commands/kudos.py @@ -12,7 +12,7 @@ emit_json, exit_for_api_error, get_current_user_uuid, - require_bearer_auth, + require_auth, resolve_team_by_name_or_uuid, resolve_user_by_name_or_uuid, ) @@ -167,7 +167,7 @@ def kudos_give( print_error(message_err) raise SystemExit(EXIT_USAGE_ERROR) - client = require_bearer_auth() + client = require_auth() user_receivers: list[tuple[str, str]] = [] team_receivers: list[tuple[str, str]] = [] diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 846fdf8..1f73c71 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -9,7 +9,7 @@ import questionary from dailybot_cli.api_client import APIError, DailyBotClient -from dailybot_cli.config import get_token +from dailybot_cli.config import get_agent_auth from dailybot_cli.display import error_console, print_error, print_info USER_SCOPED_MODEL_HELP: str = ( @@ -63,11 +63,15 @@ class InteractiveAbort(Exception): ) -def require_bearer_auth() -> DailyBotClient: - """Ensure a CLI Bearer session exists and return a client.""" - token: str | None = get_token() - if not token: - print_error("Not logged in. Run: dailybot login") +def require_auth() -> DailyBotClient: + """Ensure a login session or API key exists and return a client. + + User-scoped endpoints accept either a Bearer login token or an org API key, + so this helper succeeds under either credential and only fails when neither + is present. + """ + if get_agent_auth() is None: + print_error("Not authenticated. Run: dailybot login or set DAILYBOT_API_KEY") raise SystemExit(EXIT_NOT_AUTHENTICATED) return DailyBotClient() @@ -258,8 +262,17 @@ def resolve_team_by_name_or_uuid( def get_current_user_uuid(client: DailyBotClient) -> str | None: - """Return the authenticated user's UUID from auth status, if available.""" - data: dict[str, Any] = client.auth_status() + """Return the authenticated user's UUID from auth status, if available. + + Reads the Bearer-only ``/v1/cli/auth/status/`` endpoint. Under API-key + authentication that endpoint rejects the request, so this returns ``None`` + rather than propagating the error — callers treat an unknown current user as + "skip the client-side self-check" and let the server enforce its own rules. + """ + try: + data: dict[str, Any] = client.auth_status() + except APIError: + return None user_raw: Any = data.get("user") if isinstance(user_raw, dict): uuid_value: Any = user_raw.get("uuid") diff --git a/dailybot_cli/commands/status.py b/dailybot_cli/commands/status.py index 16a0172..4b424f7 100644 --- a/dailybot_cli/commands/status.py +++ b/dailybot_cli/commands/status.py @@ -5,7 +5,7 @@ import click from dailybot_cli.api_client import APIError, DailyBotClient -from dailybot_cli.config import get_api_key, get_token +from dailybot_cli.config import get_agent_auth, get_api_key, get_token from dailybot_cli.display import ( console, print_auth_status, @@ -69,9 +69,8 @@ def status(auth: bool) -> None: _check_auth() return - token: str | None = get_token() - if not token: - print_error("Not logged in. Run: dailybot login") + if get_agent_auth() is None: + print_error("Not authenticated. Run: dailybot login or set DAILYBOT_API_KEY") raise SystemExit(1) client: DailyBotClient = DailyBotClient() @@ -82,7 +81,7 @@ def status(auth: bool) -> None: print_pending_checkins(checkins) except APIError as e: if e.status_code in (401, 403): - print_error("Session expired. Please log in again: dailybot login") + print_error("Authentication failed. Run: dailybot login or check DAILYBOT_API_KEY") else: print_error(e.detail) raise SystemExit(1) diff --git a/dailybot_cli/commands/team.py b/dailybot_cli/commands/team.py index ed07eca..98ddcc3 100644 --- a/dailybot_cli/commands/team.py +++ b/dailybot_cli/commands/team.py @@ -10,7 +10,7 @@ emit_json, exit_for_api_error, print_error, - require_bearer_auth, + require_auth, resolve_team_by_name_or_uuid, ) from dailybot_cli.display import ( @@ -64,7 +64,7 @@ def team_list(json_mode: bool) -> None: dailybot team list dailybot team list --json """ - client = require_bearer_auth() + client = require_auth() try: with console.status("Fetching teams..."): teams: list[dict[str, Any]] = client.list_teams() @@ -94,7 +94,7 @@ def team_get(team_identifier: str, with_members: bool, json_mode: bool) -> None: dailybot team get "Engineering" dailybot team get "Engineering" --with-members --json """ - client = require_bearer_auth() + client = require_auth() team_uuid, _team_name = _resolve_team_arg(client, team_identifier, json_mode=json_mode) try: diff --git a/dailybot_cli/commands/update.py b/dailybot_cli/commands/update.py index 3ab8d16..31fd895 100644 --- a/dailybot_cli/commands/update.py +++ b/dailybot_cli/commands/update.py @@ -6,15 +6,18 @@ import httpx from dailybot_cli.api_client import APIError, DailyBotClient -from dailybot_cli.config import get_token +from dailybot_cli.config import get_agent_auth from dailybot_cli.display import console, print_error, print_info, print_update_result def _require_auth() -> DailyBotClient: - """Ensure user is authenticated, return a client.""" - token: str | None = get_token() - if not token: - print_error("Not logged in. Run: dailybot login") + """Ensure user is authenticated, return a client. + + Accepts either a Bearer login session or an org API key — the server now + honours ``X-API-KEY`` on ``POST /v1/cli/updates/``. + """ + if get_agent_auth() is None: + print_error("Not authenticated. Run: dailybot login or set DAILYBOT_API_KEY") raise SystemExit(1) return DailyBotClient() diff --git a/dailybot_cli/commands/user.py b/dailybot_cli/commands/user.py index 2187784..bf1ba3e 100644 --- a/dailybot_cli/commands/user.py +++ b/dailybot_cli/commands/user.py @@ -2,7 +2,7 @@ import click -from dailybot_cli.commands.public_api_helpers import require_bearer_auth +from dailybot_cli.commands.public_api_helpers import require_auth from dailybot_cli.commands.user_scoped_actions import execute_user_list @@ -36,5 +36,5 @@ def user_list(include_inactive: bool, json_mode: bool) -> None: dailybot user list --include-inactive dailybot user list --json """ - client = require_bearer_auth() + client = require_auth() execute_user_list(client, json_mode=json_mode, include_inactive=include_inactive) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 9128d1a..750c9cd 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -73,7 +73,7 @@ Persists to `~/.config/dailybot/config.json` (`0o600`). --- -### `dailybot checkin` (group) — user-scoped, Bearer auth +### `dailybot checkin` (group) — user-scoped, Bearer or API key auth #### `dailybot checkin list [--json]` @@ -94,7 +94,7 @@ Interactive path: prompts each question using type-aware inputs (text, numeric, --- -### `dailybot form` (group) — user-scoped, Bearer auth +### `dailybot form` (group) — user-scoped, Bearer or API key auth #### `dailybot form list [--json]` @@ -136,7 +136,7 @@ Deletes a response via `DELETE /v1/forms//responses//`. Allowed --- -### `dailybot kudos` (group) — user-scoped, Bearer auth +### `dailybot kudos` (group) — user-scoped, Bearer or API key auth #### `dailybot kudos give [--to ] [--team ] --message [--value ] [--yes] [--json]` @@ -157,7 +157,7 @@ Self-kudos via `--to` is rejected client-side (exit code 4). Ambiguous name matc --- -### `dailybot user` (group) — user-scoped, Bearer auth +### `dailybot user` (group) — user-scoped, Bearer or API key auth #### `dailybot user list [--json]` @@ -165,7 +165,7 @@ Lists organization members. Calls `GET /v1/users/` with automatic pagination (ca --- -### `dailybot team` (group) — user-scoped, Bearer auth +### `dailybot team` (group) — user-scoped, Bearer or API key auth #### `dailybot team list [--json]` @@ -370,14 +370,21 @@ All endpoints are POSTed to `{api_url}/v1/...`. The default `api_url` is `https: | `GET` | `/v1/cli/auth/status/` | — | `{ user: {email}, organization: {name, uuid} }` | Bearer | | `POST` | `/v1/cli/auth/logout/` | — | `{ detail }` | Bearer | -### Human (Bearer) +### CLI-personal (X-API-KEY *or* Bearer) + +These accept **either** an org API key (`X-API-KEY`) or a Bearer login token; the server resolves the acting user from the key's owner. | Method | Path | Request | Response | Notes | |--------|------|---------|----------|-------| | `POST` | `/v1/cli/updates/` | `{ message?, done?, doing?, blocked? }` | `{ followups_count, attached_followups: [{followup_name, action}] }` | 120s timeout (AI parsing) | -| `GET` | `/v1/cli/status/` | — | `{ pending_checkins: [{followup_name, template_questions}] }` | | +| `GET` | `/v1/cli/status/` | — | `{ pending_checkins: [{followup_name, template_questions}] }` | Also backs `dailybot checkin list` | + +### User-scoped (X-API-KEY *or* Bearer) -### User-scoped (Bearer) +These endpoints accept **either** an org API key (`X-API-KEY`) or a Bearer login +token. The CLI prefers the login session when present and falls back to the API +key, so all of these commands work with `DAILYBOT_API_KEY` set even without +`dailybot login`. | Method | Path | Request | Response | Notes | |--------|------|---------|----------|-------| @@ -423,8 +430,13 @@ All endpoints are POSTed to `{api_url}/v1/...`. The default `api_url` is `https: ``` def _headers(authenticated=True): h = {Content-Type, Accept} - if authenticated and self.token: - h["Authorization"] = f"Bearer {self.token}" + if authenticated: + if self.token: + h["Authorization"] = f"Bearer {self.token}" # ← preferred for user endpoints + self._agent_auth_mode = "bearer" + elif self.api_key: + h["X-API-KEY"] = self.api_key # ← fallback when no login session + self._agent_auth_mode = "api_key" return h def _agent_headers(): @@ -440,7 +452,11 @@ def _agent_headers(): return h ``` -The `_agent_auth_mode` is used by `_handle_response` to produce a "Session expired" message on 401/403 only when the auth came from a Bearer token (not from an API key, where the wording would be misleading). +Both helpers accept **either** credential; they differ only in preference order +(`_headers` is Bearer-first, `_agent_headers` is API-key-first). The +`_agent_auth_mode` is used by `_handle_response` to produce a "Session expired" +message on 401/403 only when the auth came from a Bearer token (not from an API +key, where the wording would be misleading). ## Error Translation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 887571e..eddb518 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,10 +56,11 @@ The single HTTP boundary. Owns: - **`DailyBotClient`** — a thin httpx wrapper. Constructor accepts `api_url`, `token`, `api_key`, `timeout` and falls back to `config` lookups when omitted. - **`APIError(status_code, detail)`** — every non-2xx response becomes one of these. Command callbacks translate them; raw `httpx` exceptions never reach the user. - **Header helpers**: - - `_headers(authenticated=True)` — used by **human** endpoints; sends `Authorization: Bearer `. - - `_agent_headers()` — used by **agent** endpoints; sends `X-API-KEY` if available, falls back to `Authorization: Bearer `. Tracks the chosen mode in `_agent_auth_mode` so `_handle_response` can produce the right error message on 401/403. + - `_headers(authenticated=True)` — used by **human** and **user-scoped** endpoints; prefers `Authorization: Bearer ` and falls back to `X-API-KEY` when no login session is present. Tracks the chosen mode in `_agent_auth_mode` so `_handle_response` can produce the right error message on 401/403. + - `_agent_headers()` — used by **agent** endpoints; prefers `X-API-KEY` and falls back to `Authorization: Bearer `. Also tracks `_agent_auth_mode`. + - The two helpers differ only in credential *preference* (Bearer-first vs. API-key-first); both accept either credential. -**Three auth schemes.** Human endpoints (`/v1/cli/*`) and user-scoped endpoints (`/v1/checkins/*`, `/v1/forms/*`, `/v1/users/`, `/v1/kudos/`) only accept Bearer tokens. Agent endpoints (`/v1/agent*/*`) accept either API key or Bearer. The split is intentional and reflects the platform's security model. +**Two auth schemes.** Nearly every endpoint the CLI uses — user-scoped (`/v1/checkins/*`, `/v1/forms/*`, `/v1/teams/*`, `/v1/users/`, `/v1/kudos/`), agent (`/v1/agent*/*`), chat (`/v1/send-message/`), **and the CLI-personal endpoints** (`/v1/cli/status/`, `/v1/cli/updates/`) — accepts **either** an org API key (`X-API-KEY`) or a Bearer login token. The server resolves the acting user from the API key's owner, so `X-API-KEY` and Bearer are behaviorally identical. Every CLI command therefore works after `dailybot login` *or* with `DAILYBOT_API_KEY` set. Only the auth-lifecycle endpoints (`/v1/cli/auth/*`: OTP request/verify, logout) are inherently Bearer/OTP. **Two different timeouts.** Most calls use `self.timeout` (default 30s). The `submit_update` call uses 120s because the AI parsing on the backend can take a while. Add new long-running calls to a named constant rather than inlining a number. @@ -123,7 +124,7 @@ Specific notes per file: - **`form.py`** — thin Click group (`list`, `submit`). Delegates to `user_scoped_actions.py`. - **`kudos.py`** — thin Click group (`give`). Contains `execute_kudos_give` (the shared handler used by both CLI and interactive mode). - **`user.py`** — thin Click group (`list`). Delegates to `user_scoped_actions.py`. -- **`public_api_helpers.py`** — shared helpers for user-scoped commands: `require_bearer_auth`, `exit_for_api_error`, `confirm_write`, `pick_from_list`, `InteractiveAbort`, `resolve_user_by_name_or_uuid`, exit-code constants. Analogous to `_resolve_agent_context` but for Bearer-only commands. +- **`public_api_helpers.py`** — shared helpers for user-scoped commands: `require_auth`, `exit_for_api_error`, `confirm_write`, `pick_from_list`, `InteractiveAbort`, `resolve_user_by_name_or_uuid`, exit-code constants. `require_auth` accepts either an API key or a Bearer session (via `get_agent_auth`). - **`user_scoped_actions.py`** — shared action logic extracted from command modules. Contains `execute_checkin_list`, `execute_checkin_complete`, `execute_form_list`, `execute_form_submit`, `execute_user_list`, `collect_checkin_answers`, `_prompt_form_answer` (type-aware prompts). Enables code reuse between CLI commands and the interactive TUI. - **`config.py`** — minimal get/set/remove for stored settings. Only `key` (→ `api_key`) is currently a known setting; adding new ones is a 1-line `KNOWN_SETTINGS` change. - **`interactive.py`** — questionary-based TUI. Calls into `auth._do_login` if not already authenticated; otherwise loops a grouped menu (Check-ins / Forms / Team / Session). Uses stable action IDs (`ACTION_*` constants) dispatched through `_HANDLER_MAP`. Pressing Esc in any sub-prompt raises `InteractiveAbort`, returning to the main menu. @@ -186,7 +187,7 @@ The codebase uses modern Python typing throughout: `dict[str, Any]`, `list[dict[ |----------------|-----------| | A new command | `dailybot_cli/commands/.py` + register in `main.py` | | A subcommand of `agent` | `dailybot_cli/commands/agent.py` (add to one of the sub-groups or as a top-level `@agent.command`) | -| A new user-scoped command | Thin Click wrapper in `dailybot_cli/commands/.py`, shared logic in `user_scoped_actions.py`, auth via `public_api_helpers.require_bearer_auth()` | +| A new user-scoped command | Thin Click wrapper in `dailybot_cli/commands/.py`, shared logic in `user_scoped_actions.py`, auth via `public_api_helpers.require_auth()` | | A new HTTP endpoint call | `dailybot_cli/api_client.py` (one method per endpoint) | | A new rendered output | `dailybot_cli/display.py` (one helper per output shape) | | A new on-disk file | `dailybot_cli/config.py` (matching read/write/clear helpers; chmod 0600) | diff --git a/docs/CLI_COMMAND_BEST_PRACTICES.md b/docs/CLI_COMMAND_BEST_PRACTICES.md index c476029..d69111f 100644 --- a/docs/CLI_COMMAND_BEST_PRACTICES.md +++ b/docs/CLI_COMMAND_BEST_PRACTICES.md @@ -127,11 +127,11 @@ Every command that talks to an authenticated endpoint must resolve credentials t | Endpoint type | Resolution helper | Returns | |---------------|-------------------|---------| -| Human (Bearer-only) | `_require_auth()` from the command file | `DailyBotClient` | -| User-scoped (Bearer-only) | `require_bearer_auth()` from `public_api_helpers.py` | `DailyBotClient` | +| CLI-personal `status` / `update` (key or Bearer) | `_require_auth()` from the command file | `DailyBotClient` | +| User-scoped (key or Bearer) | `require_auth()` from `public_api_helpers.py` | `DailyBotClient` | | Agent (key or Bearer) | `_resolve_agent_context(profile_flag, name_flag)` from `commands/agent.py` | `(agent_name, DailyBotClient)` | -**Do not duplicate the resolution logic.** If a new command needs auth, route through one of these helpers. User-scoped commands (`checkin`, `form`, `kudos`, `user`) use `require_bearer_auth()`, which exits with code `EXIT_NOT_AUTHENTICATED = 3` when no token is available. +**Do not duplicate the resolution logic.** If a new command needs auth, route through one of these helpers. User-scoped commands (`checkin`, `form`, `kudos`, `team`, `user`) use `require_auth()`, which accepts either an API key or a Bearer session and exits with code `EXIT_NOT_AUTHENTICATED = 3` only when neither credential is available. For the resolution order specifics, see [CONFIGURATION.md](CONFIGURATION.md). @@ -164,7 +164,7 @@ Avoid `sys.exit(...)` and never call `os._exit(...)`. The user-scoped commands (`checkin`, `form`, `kudos`, `user`) introduced two shared modules: -- **`public_api_helpers.py`** — auth helpers (`require_bearer_auth`), error translation (`exit_for_api_error`), interactive helpers (`confirm_write`, `pick_from_list`), name resolution (`resolve_user_by_name_or_uuid`), exit-code constants, and the `InteractiveAbort` exception. +- **`public_api_helpers.py`** — auth helpers (`require_auth`), error translation (`exit_for_api_error`), interactive helpers (`confirm_write`, `pick_from_list`), name resolution (`resolve_user_by_name_or_uuid`), exit-code constants, and the `InteractiveAbort` exception. - **`user_scoped_actions.py`** — stateless action functions (`execute_checkin_list`, `execute_form_submit`, etc.) shared between CLI commands and the interactive TUI. This pattern enables code reuse: the CLI command `dailybot form submit` and the interactive menu's "Submit a form" both call the same `execute_form_submit(...)` function. The command module is a thin Click wrapper; the action module contains the logic. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2c990e2..c5538b7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -51,14 +51,18 @@ The Dailybot CLI persists state in `~/.config/dailybot/` by default. The path ca ## Auth Resolution Order -### For `dailybot login` / `logout` / `status` / `update` / `checkin` / `form` / `kudos` / `user` +### For `dailybot login` / `logout` -These commands only accept the **login session Bearer token**. The user-scoped commands (`checkin`, `form`, `kudos`, `user`) use `require_bearer_auth()` from `public_api_helpers.py`, which exits with code 3 if no token is found. Resolution: +The OTP login flow issues the Bearer token; `logout` clears it. These are inherently tied to the login session. -1. `DAILYBOT_CLI_TOKEN` env var -2. `credentials.json::token` +### For user / CLI commands (`status` / `update` / `checkin` / `form` / `kudos` / `team` / `user` / `chat`) -If neither is present, the command exits with `Not logged in. Run: dailybot login`. +These accept **either** a login session Bearer token **or** an org API key. The `status` / `update` commands gate via their own `get_agent_auth()` check; the user-scoped commands use `require_auth()` from `public_api_helpers.py`. Either way, resolution is: + +1. Login session Bearer token (`credentials.json::token`), preferred when present +2. Org API key (`DAILYBOT_API_KEY` env var, then `config.json::api_key`) + +The command exits non-zero (`Not authenticated. Run: dailybot login or set DAILYBOT_API_KEY`) only when **neither** credential is present. The `DailyBotClient._headers()` helper prefers the Bearer token and falls back to `X-API-KEY`; the server resolves the acting user from the API key's owner, so both paths behave identically. ### For `dailybot status --auth` diff --git a/docs/ECOSYSTEM_CONTEXT.md b/docs/ECOSYSTEM_CONTEXT.md index c151bc7..71b450d 100644 --- a/docs/ECOSYSTEM_CONTEXT.md +++ b/docs/ECOSYSTEM_CONTEXT.md @@ -47,21 +47,21 @@ Everything else is API-mediated. The Dailybot API exposes three distinct endpoint families that the CLI consumes: -### Human endpoints — `/v1/cli/*` +### CLI-personal endpoints — `/v1/cli/status/`, `/v1/cli/updates/` -- Authenticate exclusively with a **Bearer token** (`Authorization: Bearer `). -- Issued by the email-OTP flow (`/v1/cli/auth/{request-code,verify-code}`). -- Scope: a single user within a single organization (the `organization_id` is implicit in the token). +- Authenticate with **either** a Bearer token (`Authorization: Bearer `) **or** an org API key (`X-API-KEY`). +- The Bearer token is issued by the email-OTP flow (`/v1/cli/auth/{request-code,verify-code}`); the API key resolves to its owning user server-side, so scope is identical. +- Scope: a single user within a single organization. -The CLI's `_headers(authenticated=True)` builds these. Used by `login`, `logout`, `status`, `update`. +The CLI's `_headers(authenticated=True)` builds these. Used by `status`, `update`. The auth-lifecycle endpoints (`/v1/cli/auth/*`, used by `login` / `logout`) remain Bearer/OTP only. -### User-scoped public API endpoints — `/v1/{checkins,forms,users,kudos}/*` +### User-scoped public API endpoints — `/v1/{checkins,forms,teams,users,kudos}/*` -- Authenticate exclusively with a **Bearer token** (same token issued by the login flow). -- Scope: the logged-in user's visibility and permissions — identical to what they see in the webapp. +- Authenticate with **either** a Bearer token (issued by the login flow) **or** an org API key (`X-API-KEY`). +- Scope: the acting user's visibility and permissions — identical to what they see in the webapp. - These endpoints are part of the public API (not the `/v1/cli/` namespace), meaning they're also usable by non-CLI clients. -The CLI's `_headers(authenticated=True)` builds these. Used by `checkin`, `form`, `kudos`, `user`. Auth is resolved through `require_bearer_auth()` in `public_api_helpers.py`. +The CLI's `_headers(authenticated=True)` builds these — it prefers the Bearer token and falls back to `X-API-KEY`. Used by `checkin`, `form`, `kudos`, `team`, `user`. Auth is resolved through `require_auth()` in `public_api_helpers.py`. ### Agent endpoints — `/v1/agent*/*` diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 0b763b3..1b8a9e3 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -85,7 +85,7 @@ The CLI supports four credential sources, resolved in this order for **agent com 4. Stored API key (`dailybot config key=...`) 5. Login session Bearer token (`dailybot login`) -For **human commands** (`status`, `update`, `logout`) and **user-scoped commands** (`checkin`, `form`, `kudos`, `user`), only the login session Bearer token is supported. These commands call `require_bearer_auth()` which checks `get_token()` and exits with code 3 if not logged in. +Every authenticated command — `status`, `update`, `checkin`, `form`, `kudos`, `team`, `user`, `chat`, and the `agent` group — works with **either** the login session Bearer token **or** an org API key. The server resolves the acting user from the API key's owner, so the two credentials are behaviorally identical. The user-scoped commands call `require_auth()` (and `status` / `update` their own equivalent check), which resolves via `get_agent_auth()` and exits non-zero only when neither credential is present. Only `dailybot logout` and the OTP login flow are inherently tied to a Bearer session. For **standalone agent registration**, no authentication is required — agents complete a math challenge to prove they're not bots. diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 4a33720..0f312e4 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -42,7 +42,7 @@ When adding a new module, mirror it in `tests/`. New test files **MUST** end in ### User-scoped command tests -The user-scoped commands (`checkin`, `form`, `team`, `kudos`, `user`) are tested in `public_api_commands_test.py`. The pattern follows the same approach as `commands_test.py` but patches `dailybot_cli.commands.public_api_helpers.get_token` and `dailybot_cli.commands.public_api_helpers.DailyBotClient` (since the auth resolution for these commands goes through `require_bearer_auth()`). +The user-scoped commands (`checkin`, `form`, `team`, `kudos`, `user`) are tested in `public_api_commands_test.py`. The pattern follows the same approach as `commands_test.py` but patches `dailybot_cli.commands.public_api_helpers.get_agent_auth` and `dailybot_cli.commands.public_api_helpers.DailyBotClient` (since the auth resolution for these commands goes through `require_auth()`, which accepts either a Bearer session or an API key). A return value of `None` from `get_agent_auth` simulates the unauthenticated case (exit code 3). **Forms-lifecycle coverage expectations.** New `form` subcommands (`get`, `responses`, `response get`, `update`, `transition`, `delete`) must include: diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 97895ae..dc28c85 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -596,3 +596,26 @@ def test_handle_response_401_api_key_unchanged(self) -> None: client._handle_response(mock_response) assert exc_info.value.detail == "Invalid API key" + + +class TestHeadersDualAuth: + def test_headers_sends_api_key_when_no_token(self) -> None: + """_headers() sends X-API-KEY when no Bearer token is available.""" + client = DailyBotClient(api_key="test-key", token=None) + headers = client._headers() + assert headers["X-API-KEY"] == "test-key" + assert "Authorization" not in headers + + def test_headers_prefers_bearer_over_api_key(self) -> None: + """_headers() sends Bearer when both token and api_key are set.""" + client = DailyBotClient(api_key="test-key", token="test-token") + headers = client._headers() + assert headers["Authorization"] == "Bearer test-token" + assert "X-API-KEY" not in headers + + def test_headers_unauthenticated_sends_neither(self) -> None: + """_headers(authenticated=False) sends no auth.""" + client = DailyBotClient(api_key="test-key", token="test-token") + headers = client._headers(authenticated=False) + assert "Authorization" not in headers + assert "X-API-KEY" not in headers diff --git a/tests/commands_test.py b/tests/commands_test.py index 9806b02..9eb9a87 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -443,7 +443,7 @@ def test_upgrade_succeeds_when_version_actually_changed(self, runner: CliRunner) assert "Upgrade complete" in result.output @patch("dailybot_cli.main.set_api_url_override") - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_api_url_override( self, @@ -788,7 +788,7 @@ def test_logout_not_logged_in(self, mock_get_token: MagicMock, runner: CliRunner class TestUpdateCommand: - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_message( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -804,7 +804,7 @@ def test_update_message( assert result.exit_code == 0 assert "1 check-in" in result.output - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_structured( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -824,7 +824,7 @@ def test_update_structured( message=None, done="Auth", doing="Tests", blocked="None" ) - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_shows_submitted_for_created( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -840,7 +840,7 @@ def test_update_shows_submitted_for_created( assert result.exit_code == 0 assert "Submitted" in result.output - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_shows_updated_for_enriched( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -856,7 +856,7 @@ def test_update_shows_updated_for_enriched( assert result.exit_code == 0 assert "Updated" in result.output - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_ai_processing_failed( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -872,7 +872,7 @@ def test_update_ai_processing_failed( assert "could not process" in result.output assert "support@dailybot.com" in result.output - @patch("dailybot_cli.commands.update.get_token") + @patch("dailybot_cli.commands.update.get_agent_auth") @patch("dailybot_cli.commands.update.DailyBotClient") def test_update_timeout( self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner @@ -887,20 +887,36 @@ def test_update_timeout( assert result.exit_code != 0 assert "timed out" in result.output - @patch("dailybot_cli.commands.update.get_token") - def test_update_not_logged_in(self, mock_get_token: MagicMock, runner: CliRunner) -> None: - mock_get_token.return_value = None + @patch("dailybot_cli.commands.update.get_agent_auth") + @patch("dailybot_cli.commands.update.DailyBotClient") + def test_update_works_with_api_key( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + """`dailybot update` works with an API key and no login session.""" + mock_get_auth.return_value = "api_key" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.submit_update.return_value = { + "followups_count": 1, + "attached_followups": [{"followup_name": "Standup"}], + } + + result = runner.invoke(cli, ["update", "Shipped the API-key parity fix"]) + assert result.exit_code == 0 + + @patch("dailybot_cli.commands.update.get_agent_auth") + def test_update_not_authenticated(self, mock_get_auth: MagicMock, runner: CliRunner) -> None: + mock_get_auth.return_value = None result = runner.invoke(cli, ["update", "test"]) assert result.exit_code != 0 class TestStatusCommand: - @patch("dailybot_cli.commands.status.get_token") + @patch("dailybot_cli.commands.status.get_agent_auth") @patch("dailybot_cli.commands.status.DailyBotClient") def test_status_with_checkins( - self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "bearer" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = { "count": 1, @@ -918,12 +934,12 @@ def test_status_with_checkins( assert result.exit_code == 0 assert "Daily Standup" in result.output - @patch("dailybot_cli.commands.status.get_token") + @patch("dailybot_cli.commands.status.get_agent_auth") @patch("dailybot_cli.commands.status.DailyBotClient") def test_status_no_checkins( - self, mock_client_cls: MagicMock, mock_get_token: MagicMock, runner: CliRunner + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "bearer" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = {"count": 0, "pending_checkins": []} @@ -931,6 +947,28 @@ def test_status_no_checkins( assert result.exit_code == 0 assert "No pending" in result.output + @patch("dailybot_cli.commands.status.get_agent_auth") + @patch("dailybot_cli.commands.status.DailyBotClient") + def test_status_works_with_api_key( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + """`dailybot status` works with an API key and no login session.""" + mock_get_auth.return_value = "api_key" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_status.return_value = {"count": 0, "pending_checkins": []} + + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + + @patch("dailybot_cli.commands.status.get_agent_auth") + def test_status_not_authenticated( + self, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + mock_get_auth.return_value = None + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 1 + assert "Not authenticated" in result.output + @patch("dailybot_cli.commands.status.get_token") @patch("dailybot_cli.commands.status.DailyBotClient") def test_status_auth_valid_login( diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 28d79f5..b03d2cf 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -42,15 +42,15 @@ def runner() -> CliRunner: class TestCheckinCommand: - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_list_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = STATUS_PAYLOAD @@ -59,15 +59,15 @@ def test_checkin_list_success( assert "Daily Standup" in result.output assert "followup-uuid-1" in result.output - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_list_json( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = STATUS_PAYLOAD @@ -77,25 +77,43 @@ def test_checkin_list_json( assert payload["count"] == 1 assert payload["pending_checkins"][0]["template_questions"][0]["index"] == 0 - @patch("dailybot_cli.commands.public_api_helpers.get_token") - def test_checkin_list_not_logged_in( + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_list_succeeds_with_api_key_only( self, - mock_get_token: MagicMock, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = None + """User-scoped commands work with an API key and no login session.""" + mock_get_auth.return_value = "api_key" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_status.return_value = STATUS_PAYLOAD + + result = runner.invoke(cli, ["checkin", "list"]) + assert result.exit_code == 0 + assert "Daily Standup" in result.output + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + def test_checkin_list_not_authenticated( + self, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = None result = runner.invoke(cli, ["checkin", "list"]) assert result.exit_code == 3 + assert "Not authenticated" in result.output - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_list_auth_failure_json( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.side_effect = APIError(401, "Unauthorized") @@ -104,15 +122,15 @@ def test_checkin_list_auth_failure_json( payload: dict[str, Any] = json.loads(result.output) assert payload["status"] == 401 - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_complete_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = STATUS_PAYLOAD mock_client.complete_checkin.return_value = {"uuid": "response-uuid"} @@ -141,15 +159,15 @@ def test_checkin_complete_success( response_date=None, ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_complete_json_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = STATUS_PAYLOAD mock_client.complete_checkin.return_value = {"uuid": "response-uuid"} @@ -171,15 +189,15 @@ def test_checkin_complete_json_success( assert result.exit_code == 0 assert json.loads(result.output) == {"uuid": "response-uuid"} - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_checkin_complete_user_abort( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_status.return_value = STATUS_PAYLOAD @@ -201,15 +219,15 @@ def test_checkin_complete_user_abort( class TestFormCommand: - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_list_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_forms.return_value = [ { @@ -225,15 +243,15 @@ def test_form_list_success( assert "Team feedback" in result.output assert "form-uuid-1" in result.output - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_forms.return_value = [{"id": "form-uuid-1", "name": "Team feedback"}] mock_client.submit_form_response.return_value = {"uuid": "response-uuid"} @@ -255,15 +273,15 @@ def test_form_submit_success( content={"question-uuid-1": "Yes"}, ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_guided_prompts( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_form.return_value = { "id": "form-uuid-1", @@ -291,16 +309,16 @@ def test_form_submit_guided_prompts( ) @patch("dailybot_cli.commands.user_scoped_actions._prompt_form_answer") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_guided_question_types( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_prompt_answer: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_form.return_value = { "id": "form-uuid-1", @@ -332,15 +350,15 @@ def test_form_submit_guided_question_types( }, ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_quota_exhausted( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_forms.return_value = [] mock_client.submit_form_response.side_effect = APIError(402, "Quota exhausted") @@ -358,15 +376,15 @@ def test_form_submit_quota_exhausted( ) assert result.exit_code == 5 - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_rate_limited_json( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_forms.return_value = [] mock_client.submit_form_response.side_effect = APIError(429, "Too many requests") @@ -389,15 +407,15 @@ def test_form_submit_rate_limited_json( class TestUserCommand: - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_user_list_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ { @@ -412,15 +430,15 @@ def test_user_list_success( assert "Jane Doe" in result.output assert "user-uuid-1" in result.output - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_user_list_json( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [{"uuid": "user-uuid-1", "full_name": "Jane Doe"}] @@ -430,15 +448,15 @@ def test_user_list_json( assert payload[0]["uuid"] == "user-uuid-1" mock_client.list_users.assert_called_once_with(include_inactive=False) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_user_list_include_inactive_flag( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [] @@ -449,16 +467,16 @@ def test_user_list_include_inactive_flag( class TestKudosCommand: @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ @@ -487,16 +505,16 @@ def test_kudos_give_success( ) @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_self_rejected( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ @@ -519,16 +537,16 @@ def test_kudos_give_self_rejected( mock_client.give_kudos.assert_not_called() @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_daily_limit( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ @@ -551,16 +569,16 @@ def test_kudos_give_daily_limit( assert result.exit_code == 4 @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_ambiguous_receiver( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ @@ -583,16 +601,16 @@ def test_kudos_give_ambiguous_receiver( assert result.exit_code == 2 @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_team_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_teams.return_value = [ @@ -621,16 +639,16 @@ def test_kudos_give_team_success( ) @patch("dailybot_cli.commands.kudos.get_current_user_uuid") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_user_and_team( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, mock_current_uuid: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_current_uuid.return_value = "self-uuid" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_users.return_value = [ @@ -663,15 +681,15 @@ def test_kudos_give_user_and_team( company_value=None, ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_unseen_team( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_teams.return_value = [ {"uuid": "team-uuid-1", "name": "Engineering"}, @@ -693,15 +711,15 @@ def test_kudos_give_unseen_team( assert "Marketing" in result.output or "Marketing" in result.stderr_bytes.decode() mock_client.give_kudos.assert_not_called() - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_kudos_give_requires_to_or_team( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" result = runner.invoke( cli, @@ -711,15 +729,15 @@ def test_kudos_give_requires_to_or_team( class TestTeamCommand: - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_team_list_success( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_teams.return_value = [ {"uuid": "team-uuid-1", "name": "General", "active": True, "members_count": 12}, @@ -730,15 +748,15 @@ def test_team_list_success( assert "General" in result.output assert "team-uuid-1" in result.output - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_team_list_json( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_teams.return_value = [ {"uuid": "team-uuid-1", "name": "General"}, @@ -749,15 +767,15 @@ def test_team_list_json( payload: list[dict[str, Any]] = json.loads(result.output) assert payload[0]["uuid"] == "team-uuid-1" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_team_get_by_name( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_teams.return_value = [ {"uuid": "team-uuid-1", "name": "Engineering"}, @@ -768,15 +786,15 @@ def test_team_get_by_name( assert result.exit_code == 0 mock_client.get_team.assert_called_once_with("team-uuid-1") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_team_get_with_members( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value team_uuid: str = "13883b5b-7066-47aa-ad0c-63bbc89eb986" mock_client.list_teams.return_value = [ @@ -811,15 +829,15 @@ class TestFormLifecycle: ], } - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_get( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_form.return_value = self.FORM_PAYLOAD @@ -828,15 +846,15 @@ def test_form_get( payload: dict[str, Any] = json.loads(result.output) assert payload["slug"] == "code-release-form" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_responses_latest( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_form_responses.return_value = [ {"id": "r1", "current_state": "qa"}, @@ -850,15 +868,15 @@ def test_form_responses_latest( assert payload[0]["id"] == "r1" mock_client.list_form_responses.assert_called_once_with("form-uuid-1", state=None) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_responses_state_filter( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.list_form_responses.return_value = [] @@ -866,15 +884,15 @@ def test_form_responses_state_filter( assert result.exit_code == 0 mock_client.list_form_responses.assert_called_once_with("form-uuid-1", state="qa") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_response_get( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_form_response.return_value = { "id": "r1", @@ -887,15 +905,15 @@ def test_form_response_get( payload: dict[str, Any] = json.loads(result.output) assert payload["id"] == "r1" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_response_get_not_found( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.get_form_response.side_effect = APIError( 404, "Form response not found.", code="form_response_not_found" @@ -906,15 +924,15 @@ def test_form_response_get_not_found( payload: dict[str, Any] = json.loads(result.output) assert payload["code"] == "form_response_not_found" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_update( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.update_form_response.return_value = { "id": "r1", @@ -942,15 +960,15 @@ def test_form_update( content={"q-1": "PR #4242"}, ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_transition( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.transition_form_response.return_value = { "id": "r1", @@ -984,15 +1002,15 @@ def test_form_transition( note="QA assigned", ) - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_transition_forbidden( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.transition_form_response.side_effect = APIError( 403, @@ -1005,15 +1023,15 @@ def test_form_transition_forbidden( payload: dict[str, Any] = json.loads(result.output) assert payload["code"] == "form_response_change_state_forbidden" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_transition_final_state_locked( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.transition_form_response.side_effect = APIError( 403, "Locked", code="final_state_locked" @@ -1026,15 +1044,15 @@ def test_form_transition_final_state_locked( payload: dict[str, Any] = json.loads(result.output) assert payload["code"] == "final_state_locked" - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_delete( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.delete_form_response.return_value = {} @@ -1044,15 +1062,15 @@ def test_form_delete( assert payload["deleted"] is True mock_client.delete_form_response.assert_called_once_with(form_uuid="f1", response_uuid="r1") - @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_delete_forbidden( self, mock_client_cls: MagicMock, - mock_get_token: MagicMock, + mock_get_auth: MagicMock, runner: CliRunner, ) -> None: - mock_get_token.return_value = "tok" + mock_get_auth.return_value = "tok" mock_client: MagicMock = mock_client_cls.return_value mock_client.delete_form_response.side_effect = APIError( 403, "Forbidden", code="form_response_delete_forbidden" @@ -1062,3 +1080,20 @@ def test_form_delete_forbidden( assert result.exit_code == 4 payload: dict[str, Any] = json.loads(result.output) assert payload["code"] == "form_response_delete_forbidden" + + +class TestGetCurrentUserUuid: + def test_returns_uuid_when_auth_status_succeeds(self) -> None: + from dailybot_cli.commands.public_api_helpers import get_current_user_uuid + + client: MagicMock = MagicMock() + client.auth_status.return_value = {"user": {"uuid": "u-123"}} + assert get_current_user_uuid(client) == "u-123" + + def test_returns_none_when_auth_status_rejects_api_key(self) -> None: + """Under API-key auth, /v1/cli/auth/status/ 401s — degrade to None, not raise.""" + from dailybot_cli.commands.public_api_helpers import get_current_user_uuid + + client: MagicMock = MagicMock() + client.auth_status.side_effect = APIError(401, "Authentication credentials were not provided.") + assert get_current_user_uuid(client) is None From abc64a7a5b00b3210528782fd8b92aefd80b65fb Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 01:57:22 +0000 Subject: [PATCH 05/12] feat(ask): add headless `dailybot ask` AI command; deprecate `interactive` ## Summary Unify the Dailybot AI chat under a single verb, `dailybot ask`, with the mode chosen by whether a message is supplied (the psql/python/sqlite3 pattern). This gives agents and scripts a headless one-shot path to the Dailybot AI. ## Change Log - New `dailybot ask [MESSAGE] [--json] [--session-id]`: - message given (or piped stdin) -> one-shot headless answer to stdout - no message + interactive TTY -> opens the full-screen Textual chat session - Extracted `launch_chat_tui()` into interactive_chat.py (shared by both paths) - `dailybot interactive` kept as a deprecated alias for `dailybot ask` (1.14.0 compat) - display.print_ai_answer(): markup-off, soft-wrap stdout (agent-safe) - 7 new tests (headless text, --json, stdin pipe, TUI launch, auth gate, APIError) - Documented in README + docs/API_REFERENCE.md ## Notes - Auth is currently `require_bearer_auth` (Bearer-only), matching the server's Bearer-only /v1/cli/chat/completions/. Once the API accepts X-API-KEY there (full-parity work), flip to require_auth so agents can use it with a key. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 ++-- dailybot_cli/commands/ask.py | 84 ++++++++++++++++++++++ dailybot_cli/commands/interactive_chat.py | 22 ++++-- dailybot_cli/display.py | 9 +++ dailybot_cli/main.py | 2 + docs/API_REFERENCE.md | 16 ++++- tests/commands_test.py | 87 +++++++++++++++++++++++ 7 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 dailybot_cli/commands/ask.py diff --git a/README.md b/README.md index b6fc173..0ce2cdb 100644 --- a/README.md +++ b/README.md @@ -93,15 +93,21 @@ dailybot update --done "Auth module" --doing "Tests" --blocked "None" Run `dailybot` with no arguments to enter **interactive mode** — a grouped menu covering check-ins, forms, kudos, and team browsing. If you're not logged in yet, it walks you through authentication first. -Run `dailybot interactive` to start a Claude-style full-screen terminal chat where you can talk directly to Dailybot: +Use `dailybot ask` to talk directly to the Dailybot AI. It has two modes, chosen by whether you pass a message: ```bash -dailybot interactive +# One-shot (headless) — prints the answer and exits. Great for agents, CI, and scripts: +dailybot ask "Summarize my pending check-ins" +dailybot ask "What forms do I have?" --json +echo "draft my standup" | dailybot ask -# Opens a Textual chat UI with a transcript pane, prompt box, and footer shortcuts. +# No message — opens the full-screen Textual chat UI (transcript pane, prompt box, shortcuts): +dailybot ask ``` -The chat mode uses your `dailybot login` session and supports control commands: `/help`, `/clear`, `/status`, `/checkins`, `/report`, and `/exit`. It lazy-loads the Textual UI only for this command, so regular CLI commands keep their normal startup path. +The chat mode uses your `dailybot login` session and supports control commands: `/help`, `/clear`, `/status`, `/checkins`, `/report`, and `/exit`. It lazy-loads the Textual UI only when opening the session, so regular CLI commands keep their normal startup path. + +> `dailybot interactive` still works as a **deprecated alias** for `dailybot ask` (chat session), kept for backward-compatibility. --- diff --git a/dailybot_cli/commands/ask.py b/dailybot_cli/commands/ask.py new file mode 100644 index 0000000..a1994c9 --- /dev/null +++ b/dailybot_cli/commands/ask.py @@ -0,0 +1,84 @@ +"""`dailybot ask` — talk to the Dailybot AI. + +Two modes, chosen by whether a message is supplied (the same pattern as +`psql`/`python`/`sqlite3`): + +- ``dailybot ask "question"`` — one-shot headless answer printed to stdout + (ideal for agents, CI, and scripts). +- ``dailybot ask`` — opens the full-screen interactive chat session. +""" + +import sys +from typing import Any + +import click + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.interactive_chat import launch_chat_tui +from dailybot_cli.commands.public_api_helpers import ( + emit_json, + exit_for_api_error, + require_bearer_auth, +) +from dailybot_cli.display import console, print_ai_answer + +EMPTY_ANSWER: str = "Dailybot returned an empty response." + + +@click.command(name="ask") +@click.argument("message", required=False) +@click.option("--json", "json_mode", is_flag=True, help="Emit the answer as machine-readable JSON.") +@click.option( + "--session-id", + "-s", + "session_id", + default=None, + help="Continue an existing chat session by id.", +) +def ask(message: str | None, json_mode: bool, session_id: str | None) -> None: + """Ask the Dailybot AI a question, or open an interactive chat session. + + \b + With a message -> one-shot headless answer (ideal for agents and scripts): + dailybot ask "Summarize my pending check-ins" + dailybot ask "What forms do I have?" --json + echo "draft my standup" | dailybot ask + \b + Without a message -> opens the full-screen chat session: + dailybot ask + """ + # Resolve the message: explicit argument, else piped stdin. No message plus + # an interactive terminal means the human wants the full chat session. + if message is None and not sys.stdin.isatty(): + piped: str = sys.stdin.read().strip() + message = piped or None + + client: DailyBotClient = require_bearer_auth() + + if message is None: + launch_chat_tui(client) + return + + try: + with console.status("Asking Dailybot..."): + response: dict[str, Any] = client.create_chat_completion( + message=message, + session_id=session_id, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + assistant: dict[str, Any] = response.get("message") or {} + content: str = str(assistant.get("content") or "").strip() or EMPTY_ANSWER + + if json_mode: + emit_json( + { + "message": content, + "actions": response.get("actions") or [], + "classification": response.get("classification"), + "session_id": response.get("session_id") or session_id, + } + ) + else: + print_ai_answer(content) diff --git a/dailybot_cli/commands/interactive_chat.py b/dailybot_cli/commands/interactive_chat.py index aa4cf41..bdfe488 100644 --- a/dailybot_cli/commands/interactive_chat.py +++ b/dailybot_cli/commands/interactive_chat.py @@ -1,16 +1,18 @@ -"""Conversational terminal mode for Dailybot CLI.""" +"""Conversational terminal mode for Dailybot CLI (AI chat). + +`dailybot interactive` is retained as a deprecated alias for `dailybot ask` +(with no message), which is now the canonical entry point for the AI chat. +""" import click from dailybot_cli.api_client import DailyBotClient from dailybot_cli.commands.public_api_helpers import require_bearer_auth -from dailybot_cli.display import print_error, print_info +from dailybot_cli.display import print_error, print_info, print_warning -@click.command(name="interactive") -def interactive() -> None: - """Talk directly to Dailybot in a terminal chat session.""" - client: DailyBotClient = require_bearer_auth() +def launch_chat_tui(client: DailyBotClient) -> None: + """Launch the full-screen Textual AI chat session (textual is lazy-imported).""" try: from dailybot_cli.tui.app import run_chat_app except ModuleNotFoundError as exc: @@ -21,3 +23,11 @@ def interactive() -> None: raise click.ClickException("Missing dependency: textual") from exc run_chat_app(client) + + +@click.command(name="interactive") +def interactive() -> None: + """Deprecated alias for `dailybot ask` — opens the AI chat session.""" + print_warning("`dailybot interactive` is deprecated; use `dailybot ask` instead.") + client: DailyBotClient = require_bearer_auth() + launch_chat_tui(client) diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 8593ad8..97a97c4 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -32,6 +32,15 @@ def print_info(message: str) -> None: console.print(f"[dim]{message}[/dim]") +def print_ai_answer(content: str) -> None: + """Print a Dailybot AI answer to stdout verbatim. + + Markup interpretation is off and wrapping is soft, so arbitrary AI text + (brackets, long lines) survives intact when piped into another tool. + """ + console.print(content, markup=False, highlight=False, soft_wrap=True) + + def print_interactive_chat_welcome(version: str, session_id: str) -> None: """Display the conversational terminal session header.""" console.print( diff --git a/dailybot_cli/main.py b/dailybot_cli/main.py index 277aac8..8b2e79d 100644 --- a/dailybot_cli/main.py +++ b/dailybot_cli/main.py @@ -6,6 +6,7 @@ from dailybot_cli import __version__ from dailybot_cli.commands.agent import agent +from dailybot_cli.commands.ask import ask from dailybot_cli.commands.auth import login, logout from dailybot_cli.commands.chat import chat from dailybot_cli.commands.checkin import checkin @@ -78,6 +79,7 @@ def cli(ctx: click.Context, api_url: str | None) -> None: cli.add_command(user) cli.add_command(agent) cli.add_command(chat) +cli.add_command(ask) cli.add_command(interactive) cli.add_command(config) cli.add_command(hook) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index da956c4..4c8bf89 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -23,7 +23,21 @@ dailybot [--api-url URL] [--version] [ …] Run with no subcommand → drops into the menu-driven interactive TUI (`commands/interactive.py`). -### `dailybot interactive` +### `dailybot ask [MESSAGE] [--json] [--session-id ID]` + +Talk to the Dailybot AI. The mode is chosen by whether a message is supplied (same pattern as `psql`/`python`/`sqlite3`): + +- **`dailybot ask "question"`** — **headless one-shot**: sends the message to `POST /v1/cli/chat/completions/` and prints the assistant's answer to stdout, then exits. Ideal for agents, CI, and scripts. `--json` emits `{ message, actions, classification, session_id }`. A piped message also works: `echo "draft my standup" | dailybot ask`. +- **`dailybot ask`** (no message, interactive terminal) — opens the full-screen Textual chat session (multi-turn). + +| Flag | Short | Notes | +|------|-------|-------| +| `--json` | | Machine-readable answer (headless mode). | +| `--session-id` | `-s` | Continue an existing chat session by id. | + +### `dailybot interactive` (deprecated alias) + +Deprecated alias for `dailybot ask` with no message (opens the chat session). Retained for backward-compatibility with CLI 1.14.0; prints a deprecation notice. New code should use `dailybot ask`. Starts a Claude-style full-screen Textual chat session. Natural-language turns are sent to Dailybot via `POST /v1/cli/chat/completions/`; the Textual UI is lazy-loaded only when this command runs. diff --git a/tests/commands_test.py b/tests/commands_test.py index 719b55f..5fcad03 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -1,5 +1,6 @@ """Tests for CLI commands.""" +import json from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -1057,6 +1058,92 @@ def test_interactive_chat_launches_textual_app( mock_run_chat_app.assert_called_once_with(mock_client) +class TestAskCommand: + @patch("dailybot_cli.commands.ask.require_bearer_auth") + def test_ask_headless_prints_answer( + self, mock_require_auth: MagicMock, runner: CliRunner + ) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.return_value = { + "message": {"role": "assistant", "content": "You have 1 pending check-in."}, + } + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask", "What are my check-ins?"]) + + assert result.exit_code == 0 + assert "You have 1 pending check-in." in result.output + mock_client.create_chat_completion.assert_called_once_with( + message="What are my check-ins?", session_id=None + ) + + @patch("dailybot_cli.commands.ask.require_bearer_auth") + def test_ask_json_output(self, mock_require_auth: MagicMock, runner: CliRunner) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.return_value = { + "message": {"content": "Done."}, + "actions": [{"name": "open_form"}], + "classification": "action", + "session_id": "sess-1", + } + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask", "do it", "--json", "--session-id", "sess-1"]) + + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["message"] == "Done." + assert payload["actions"] == [{"name": "open_form"}] + assert payload["session_id"] == "sess-1" + mock_client.create_chat_completion.assert_called_once_with( + message="do it", session_id="sess-1" + ) + + @patch("dailybot_cli.commands.ask.launch_chat_tui") + @patch("dailybot_cli.commands.ask.require_bearer_auth") + def test_ask_without_message_launches_tui( + self, mock_require_auth: MagicMock, mock_launch_tui: MagicMock, runner: CliRunner + ) -> None: + mock_client: MagicMock = MagicMock() + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask"]) + + assert result.exit_code == 0 + mock_launch_tui.assert_called_once_with(mock_client) + mock_client.create_chat_completion.assert_not_called() + + @patch("dailybot_cli.commands.ask.require_bearer_auth") + def test_ask_reads_piped_stdin( + self, mock_require_auth: MagicMock, runner: CliRunner + ) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.return_value = {"message": {"content": "ok"}} + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask"], input="draft my standup\n") + + assert result.exit_code == 0 + mock_client.create_chat_completion.assert_called_once_with( + message="draft my standup", session_id=None + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + def test_ask_not_authenticated(self, mock_get_token: MagicMock, runner: CliRunner) -> None: + mock_get_token.return_value = None + result = runner.invoke(cli, ["ask", "hello"]) + assert result.exit_code == 3 + + @patch("dailybot_cli.commands.ask.require_bearer_auth") + def test_ask_api_error(self, mock_require_auth: MagicMock, runner: CliRunner) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.side_effect = APIError(401, "Unauthorized") + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask", "hello"]) + assert result.exit_code == 3 + + class TestInteractiveMenu: @patch("dailybot_cli.commands.interactive.pick_from_list") @patch("dailybot_cli.commands.interactive.questionary") From 8dd24b4603c849a841cc5f0e7123d0e73ee52e76 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 02:39:00 +0000 Subject: [PATCH 06/12] feat(ask): surface 429 rate-limit with Retry-After for AI chat The API server added a 30 req/min per-API-key throttle on /v1/cli/chat/completions/. Handle it cleanly: - APIError now carries retry_after (parsed from the 429 Retry-After header). - exit_for_api_error: generic rate-limit message + 'Try again in Ns' when Retry-After is present; --json payload includes retry_after_seconds. - Documented the 30/min chat limit + 429 behavior in API_REFERENCE. - Tests: APIError parses Retry-After; ask surfaces 429 in text and --json. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/api_client.py | 24 +++++++++++++++++-- dailybot_cli/commands/public_api_helpers.py | 8 ++++++- docs/API_REFERENCE.md | 4 +++- tests/api_client_test.py | 17 ++++++++++++++ tests/commands_test.py | 26 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index ac598df..bf0506c 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -12,10 +12,17 @@ class APIError(Exception): """Raised when the API returns a non-success response.""" - def __init__(self, status_code: int, detail: str, code: str | None = None) -> None: + def __init__( + self, + status_code: int, + detail: str, + code: str | None = None, + retry_after: float | None = None, + ) -> None: self.status_code: int = status_code self.detail: str = detail self.code: str | None = code + self.retry_after: float | None = retry_after # seconds, from a 429 Retry-After header super().__init__(f"API error {status_code}: {detail}") @@ -85,7 +92,20 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: detail = response.text or f"HTTP {response.status_code}" if response.status_code in (401, 403) and self._agent_auth_mode == "bearer": detail = "Session expired. Run 'dailybot login' to re-authenticate." - raise APIError(status_code=response.status_code, detail=detail, code=code) + retry_after: float | None = None + if response.status_code == 429: + raw_retry: str | None = response.headers.get("Retry-After") + if raw_retry: + try: + retry_after = float(raw_retry) + except ValueError: + retry_after = None + raise APIError( + status_code=response.status_code, + detail=detail, + code=code, + retry_after=retry_after, + ) if response.status_code == 204: return {} return response.json() diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 1f73c71..7ea34b5 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -107,7 +107,10 @@ def exit_for_api_error(exc: APIError, json_mode: bool) -> NoReturn: message = "Daily kudos limit reached." exit_code = EXIT_PERMISSION_DENIED elif exc.status_code == 429: - message = "Rate limit exceeded (60 requests/minute). Wait and try again." + retry_after: float | None = getattr(exc, "retry_after", None) + message = "Rate limit exceeded. Wait a moment and try again." + if retry_after: + message = f"Rate limit exceeded. Try again in {int(retry_after)}s." exit_code = EXIT_RATE_LIMITED elif exc.status_code == 400: message = mapped_message or exc.detail @@ -122,6 +125,9 @@ def exit_for_api_error(exc: APIError, json_mode: bool) -> NoReturn: payload["code"] = code if exc.detail and exc.detail != message: payload["detail"] = exc.detail + retry_after_seconds: float | None = getattr(exc, "retry_after", None) + if exc.status_code == 429 and retry_after_seconds is not None: + payload["retry_after_seconds"] = int(retry_after_seconds) emit_json(payload) else: print_error(message) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 62727be..c2748e4 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -35,6 +35,8 @@ Talk to the Dailybot AI. The mode is chosen by whether a message is supplied (sa | `--json` | | Machine-readable answer (headless mode). | | `--session-id` | `-s` | Continue an existing chat session by id. | +The chat endpoint is throttled to **30 requests/minute per API key**. On a `429`, `dailybot ask` exits with code `6` and a "Rate limit exceeded. Try again in Ns." message; in `--json` mode the payload carries `retry_after_seconds` (from the `Retry-After` header). + ### `dailybot interactive` (deprecated alias) Deprecated alias for `dailybot ask` with no message (opens the chat session). Retained for backward-compatibility with CLI 1.14.0; prints a deprecation notice. New code should use `dailybot ask`. @@ -408,7 +410,7 @@ These accept **either** an org API key (`X-API-KEY`) or a Bearer login token; th | `POST` | `/v1/cli/updates/` | `{ message?, done?, doing?, blocked? }` | `{ followups_count, attached_followups: [{followup_name, action}] }` | 120s timeout (AI parsing) | | `GET` | `/v1/cli/status/` | — | `{ pending_checkins: [{followup_name, template_questions}] }` | Also backs `dailybot checkin list` | | `GET` | `/v1/cli/auth/status/` | — | `{ authenticated, user: {uuid, email, full_name}, organization: {id, name, uuid} }` | Session/identity; resolves the API key's owner too | -| `POST` | `/v1/cli/chat/completions/` | `{ message?, history?, messages?, session_id?, reset_thread?, available_commands? }` | `{ status, async, correlation_id, classification, message: {role, content}, actions }` | AI chat (`dailybot ask` / `interactive`); 120s timeout | +| `POST` | `/v1/cli/chat/completions/` | `{ message?, history?, messages?, session_id?, reset_thread?, available_commands? }` | `{ status, async, correlation_id, classification, message: {role, content}, actions }` | AI chat (`dailybot ask` / `interactive`); 120s timeout; **30 req/min per API key** (429 carries `Retry-After`) | ### User-scoped (X-API-KEY *or* Bearer) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 9afd355..3c34d15 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -447,6 +447,23 @@ def test_api_error_carries_code(self, client: DailyBotClient) -> None: return raise AssertionError("APIError not raised") + def test_api_error_429_carries_retry_after(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 429 + mock_response.json.return_value = {"detail": "Request was throttled."} + mock_response.headers = {"Retry-After": "2"} + + from dailybot_cli.api_client import APIError as _APIError + + with patch("httpx.post", return_value=mock_response): + try: + client.create_chat_completion(message="hi") + except _APIError as exc: + assert exc.status_code == 429 + assert exc.retry_after == 2.0 + return + raise AssertionError("APIError not raised") + class TestDailyBotClientAgent: def test_submit_agent_report(self, client: DailyBotClient) -> None: diff --git a/tests/commands_test.py b/tests/commands_test.py index c72db97..2099942 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -1195,6 +1195,32 @@ def test_ask_api_error(self, mock_require_auth: MagicMock, runner: CliRunner) -> result = runner.invoke(cli, ["ask", "hello"]) assert result.exit_code == 3 + @patch("dailybot_cli.commands.ask.require_auth") + def test_ask_rate_limited_text(self, mock_require_auth: MagicMock, runner: CliRunner) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.side_effect = APIError( + 429, "Request was throttled.", retry_after=2.0 + ) + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask", "hello"]) + assert result.exit_code == 6 + assert "Try again in 2s" in result.output + + @patch("dailybot_cli.commands.ask.require_auth") + def test_ask_rate_limited_json(self, mock_require_auth: MagicMock, runner: CliRunner) -> None: + mock_client: MagicMock = MagicMock() + mock_client.create_chat_completion.side_effect = APIError( + 429, "Request was throttled.", retry_after=2.0 + ) + mock_require_auth.return_value = mock_client + + result = runner.invoke(cli, ["ask", "hello", "--json"]) + assert result.exit_code == 6 + payload: dict[str, Any] = json.loads(result.output) + assert payload["status"] == 429 + assert payload["retry_after_seconds"] == 2 + class TestInteractiveMenu: @patch("dailybot_cli.commands.interactive.pick_from_list") From bf213a0607e4e3e88dc2e0f7b53b3b331992ce59 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 03:18:04 +0000 Subject: [PATCH 07/12] feat(interactive): add "Ask the Dailybot AI" menu entry that opens the chat Adds a discovery hook for `dailybot ask`: the no-args menu TUI now has a top-level "Dailybot AI" section whose "Ask the Dailybot AI (opens chat)" option launches the full-screen chat (launch_chat_tui). Leaving the chat (`/exit`) drops back into the menu loop, so users discover the AI chat from the menu they already use. - interactive.py: ACTION_ASK_AI + menu section + _ask_ai handler (lazy-imports the TUI so the menu never loads textual unless the AI option is chosen). - Test: selecting the AI option launches the chat and returns to the menu. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/commands/interactive.py | 15 ++++++++++++++ tests/commands_test.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/dailybot_cli/commands/interactive.py b/dailybot_cli/commands/interactive.py index 30099dc..cee6a78 100644 --- a/dailybot_cli/commands/interactive.py +++ b/dailybot_cli/commands/interactive.py @@ -39,6 +39,7 @@ ) # Stable action IDs — dispatch is keyed on these, never on display strings. +ACTION_ASK_AI: str = "ask.ai" ACTION_CHECKIN_COMPLETE: str = "checkin.complete" ACTION_CHECKIN_UPDATE: str = "checkin.update" ACTION_FORM_LIST: str = "form.list" @@ -53,6 +54,8 @@ _I: str = " " # indent for action items — 2 spaces under each section header MENU_CHOICES: list[Choice | Separator] = [ + Separator("Dailybot AI"), + Choice(_I + "Ask the Dailybot AI (opens chat)", value=ACTION_ASK_AI), Separator("Check-ins"), Choice(_I + "Complete pending check-ins", value=ACTION_CHECKIN_COMPLETE), Choice(_I + "Send free-text update", value=ACTION_CHECKIN_UPDATE), @@ -72,6 +75,7 @@ # Action → handler lookup — keeps run_interactive() free of long if/elif chains. _HANDLER_MAP: dict[str, str] = { + ACTION_ASK_AI: "_ask_ai", ACTION_CHECKIN_COMPLETE: "_fill_pending_checkins", ACTION_CHECKIN_UPDATE: "_send_update", ACTION_FORM_LIST: "_list_forms", @@ -182,6 +186,17 @@ def run_interactive() -> None: _return_to_menu() +def _ask_ai(client: DailyBotClient) -> None: + """Open the full-screen Dailybot AI chat; control returns to the menu on exit. + + A discovery hook for `dailybot ask` — a menu user finds the AI chat here, and + leaving the chat (``/exit``) drops back into this menu loop. + """ + from dailybot_cli.commands.interactive_chat import launch_chat_tui + + launch_chat_tui(client) + + def _fill_pending_checkins(client: DailyBotClient) -> None: """Pick a pending check-in and guide the user through completing it.""" try: diff --git a/tests/commands_test.py b/tests/commands_test.py index 2099942..0cbcf8c 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -1266,6 +1266,37 @@ def test_interactive_give_kudos_picks_teammate( assert call_args.kwargs["user_receivers"] == [("peer-uuid", "Jane Doe")] assert call_args.kwargs["assume_yes"] is True + @patch("dailybot_cli.commands.interactive_chat.launch_chat_tui") + @patch("dailybot_cli.commands.interactive.questionary") + @patch("dailybot_cli.commands.interactive.load_credentials") + @patch("dailybot_cli.commands.interactive.get_token") + @patch("dailybot_cli.commands.interactive.DailyBotClient") + def test_interactive_menu_ask_ai_launches_chat_and_returns( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_load_creds: MagicMock, + mock_questionary: MagicMock, + mock_launch_tui: MagicMock, + runner: CliRunner, + ) -> None: + """Picking the AI option opens the chat TUI, then the loop returns to the menu.""" + mock_get_token.return_value = "tok" + mock_load_creds.return_value = { + "token": "tok", + "email": "me@example.com", + "organization": "Org", + "api_url": "https://api.dailybot.com", + } + mock_client: MagicMock = mock_client_cls.return_value + # Select "Ask the Dailybot AI", then Exit — proving control returns to the menu. + mock_questionary.select.return_value.ask.side_effect = ["ask.ai", "exit"] + + result = runner.invoke(cli, []) + assert result.exit_code == 0 + mock_launch_tui.assert_called_once_with(mock_client) + assert mock_questionary.select.return_value.ask.call_count == 2 + @patch("dailybot_cli.commands.interactive.get_api_key") @patch("dailybot_cli.commands.interactive.questionary") @patch("dailybot_cli.commands.interactive.load_credentials") From 712362b43205ef82ba91fed526c5b12569d83cd3 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 03:27:37 +0000 Subject: [PATCH 08/12] docs(security): redact real internal identifiers + add open-source privacy rule Security audit hardening (both this repo and the vendored skill are public): - Replaced a real org UUID, a real form UUID, and internal team/repo names ('API Services' / 'api-services') in the forms custom-template example with placeholders (, , My Team, my-service). - Removed an explicit pointer to a Dailybot-internal repo skill path; described the reference implementation generically instead. - Genericized the api-services metadata example in README. - Added AGENTS.md rule 11.a: open-source privacy hygiene (no real credentials, UUIDs, emails, or internal infra in tracked files; scratch stays in tmp/). Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/dailybot/forms/SKILL.md | 8 ++++---- .../skills/dailybot/forms/_custom-template/SKILL.md | 2 +- AGENTS.md | 11 +++++++++++ README.md | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 6f804db..a59d0ad 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -284,13 +284,13 @@ In `.dailybot/profile.json` at the repo root, under the CLI's free-form `vars` n ```json { - "name": "API Services", - "default_metadata": { "repo": "api-services" }, + "name": "My Team", + "default_metadata": { "repo": "my-service" }, "vars": { - "active_organization_uuid": "00e2b30f-b581-44ae-8981-4cdbd060b78d", + "active_organization_uuid": "", "custom_form_skills": { "by_uuid": { - "65de0ec6-2353-4e17-94d7-7beaa905e92a": ".agents/skills/dailybot-custom/coderelease-form" + "": ".agents/skills/dailybot-custom/coderelease-form" }, "by_slug": { "code-release-form": ".agents/skills/dailybot-custom/coderelease-form" diff --git a/.agents/skills/dailybot/forms/_custom-template/SKILL.md b/.agents/skills/dailybot/forms/_custom-template/SKILL.md index 37774c1..f684b6a 100644 --- a/.agents/skills/dailybot/forms/_custom-template/SKILL.md +++ b/.agents/skills/dailybot/forms/_custom-template/SKILL.md @@ -114,7 +114,7 @@ What does the agent do when: ## 7 — Reference implementation -For a complete reference of this template applied to a real workflow form, see the Dailybot-internal code-release skill at `api-services/.agents/skills/dailybot-custom/coderelease-form/SKILL.md` (ships in the `api-services` repo, separate from this skill pack). It demonstrates per-state validation, autofill from repo metadata, and channel-routing reminders end-to-end. +A complete reference implementation of this template lives as a custom skill at `.agents/skills/dailybot-custom//SKILL.md` in the consuming repo (a customer- or team-owned namespace, separate from this skill pack). Such a skill demonstrates per-state validation, autofill from repo metadata, and channel-routing reminders end-to-end. --- diff --git a/AGENTS.md b/AGENTS.md index 85af0fe..64c6a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,17 @@ os.chmod(PATH, 0o600) Never log full API keys or Bearer tokens. Use the `_mask` helper (or equivalent: first 4 chars + `****`). +#### 11.a Open-source privacy hygiene — this repo is PUBLIC + +`DailybotHQ/cli` is a **public, open-source repository**. Anything you commit is world-readable forever (git history included). Never put **real internal or personal data** into tracked files — source, docs, tests, examples, or help text. This is a hard rule, and it applies even to "just an example": + +- **No real credentials/tokens** — no API keys, Bearer tokens, session tokens, webhook secrets, or `.env` values. Tests use obvious fakes (`"test-key"`, `"test-token"`); docs use `` / `DAILYBOT_API_KEY`. +- **No real identifiers or PII** — no real organization/user/form UUIDs, no real emails or names. Use placeholders: ``, ``, `me@example.com`, `Jane Doe`, `My Team`. (`security@dailybot.com` / `ops@dailybot.com` are the *intended* public contacts and are fine.) +- **No internal infrastructure** — no internal hostnames (`djangovscode`, staging/dev URLs), no private repo names or internal architecture in **shipped/user-facing** content. Prefer generic example names (`my-service`, `my-api`) over real internal repo names. (The `docker/` local-dev tooling is a deliberate exception — it targets the internal dev container — but keep its internal references out of user-facing docs and the CLI's own output.) +- **Scratch with real data stays in `tmp/`** — screenshots, API dumps, and probe output that contain real org UUIDs / emails / hosts go in `tmp/` (gitignored) and are **never** promoted into the repo. Do not `git add -A` blindly; prefer `git add -u` + explicit paths so stray artifacts (e.g. a debug `image.png` at the repo root) can't slip in. + +When in doubt, mask or genericize. To report a vulnerability, see [docs/SECURITY.md](docs/SECURITY.md) — never open a public issue for a security problem. + ### 12. No Magic Numbers Extract limits, timeouts, and counts into module-level constants: diff --git a/README.md b/README.md index 0ce2cdb..0504157 100644 --- a/README.md +++ b/README.md @@ -477,7 +477,7 @@ dailybot agent update "Sprint progress" --name "Claude Code" --json-data '{ }' # Attach metadata (repo, branch, PR, or any key-value context) -dailybot agent update "Fixed login bug" --name "Claude Code" --metadata '{"repo": "api-services", "branch": "fix/login", "pr": "#142"}' +dailybot agent update "Fixed login bug" --name "Claude Code" --metadata '{"repo": "my-service", "branch": "fix/login", "pr": "#142"}' # Mark a report as a milestone dailybot agent update "Shipped v3.0" --milestone --name "Claude Code" From 53355c993af1356b19acccf4f292dca6513fe4a1 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 05:39:15 +0000 Subject: [PATCH 09/12] =?UTF-8?q?refactor(tui):=20harden=20and=20polish=20?= =?UTF-8?q?Andr=C3=A9s's=20terminal=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review + fixes on the expanded chat TUI (PR #31 folded into #32): Bugs: - Dashboard link was corrupted for the default host (substring replace of '/api' -> 'https:/.dailybot.com'); derive the web host properly and cover it with a testable _web_base_url() + regression tests. - Check-in submit could POST to an empty followup UUID (missing 'id' fallback). - Check-in edit matched the current answer by list position; now keyed by question UUID with a positional fallback (like the form-update path). Robustness: - Every flow now catches httpx transport errors (timeouts / connection drops), not just APIError — a network blip no longer crashes the Textual app (_format_api_error handles both; 28 call sites broadened). - @-mention autocomplete no longer caches its first failure forever or writes a chat error on a keystroke; it retries and fails silently. - Tab-completion workers run in their own group so they can't cancel the startup user-label worker. Cleanup: - Extracted _parse_confirmation() + yes/no frozensets (was duplicated across 4 confirm handlers); dropped dead getattr(event, 'shift') code. - Documented the expanded slash commands + new check-in/mood endpoints and the kudos payload in docs/API_REFERENCE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/tui/app.py | 153 ++++++++++++++++++++++-------------- docs/API_REFERENCE.md | 26 ++++-- tests/tui_app_flows_test.py | 9 +++ 3 files changed, 125 insertions(+), 63 deletions(-) diff --git a/dailybot_cli/tui/app.py b/dailybot_cli/tui/app.py index 521d2c6..eca2a58 100644 --- a/dailybot_cli/tui/app.py +++ b/dailybot_cli/tui/app.py @@ -64,6 +64,19 @@ ▀██████▀ """.strip("\n") +_CONFIRM_YES: frozenset[str] = frozenset({"1", "yes", "y"}) +_CONFIRM_NO: frozenset[str] = frozenset({"2", "no", "n"}) + + +def _parse_confirmation(raw_value: str, *, extra_yes: frozenset[str] = frozenset()) -> bool | None: + """Parse a yes/no reply: True = confirm, False = cancel, None = unrecognized.""" + normalized: str = raw_value.strip().lower() + if normalized in _CONFIRM_YES or normalized in extra_yes: + return True + if normalized in _CONFIRM_NO: + return False + return None + class DailybotChatApp(App[None]): """Claude-style terminal chat for talking directly to Dailybot.""" @@ -188,15 +201,20 @@ def on_key(self, event: Key) -> None: if event.key == "tab": event.prevent_default() event.stop() + # Own worker group so Tab-cycling only cancels prior completions, not + # the startup user-label worker (shift+tab is handled separately below). self.run_worker( - self._complete_prompt(reverse=bool(getattr(event, "shift", False))), + self._complete_prompt(reverse=False), exclusive=True, + group="completion", ) return if event.key in {"shift+tab", "shift_tab", "backtab"}: event.prevent_default() event.stop() - self.run_worker(self._complete_prompt(reverse=True), exclusive=True) + self.run_worker( + self._complete_prompt(reverse=True), exclusive=True, group="completion" + ) async def on_input_submitted(self, event: Input.Submitted) -> None: prompt = self.query_one("#prompt", Input) @@ -395,7 +413,7 @@ async def _send_turn(self, message: str) -> None: except httpx.TimeoutException: self._write_error("Dailybot took longer than expected to answer. Please try again.") return - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -421,7 +439,7 @@ async def _show_status(self) -> None: asyncio.to_thread(self.client.auth_status), asyncio.to_thread(self.client.get_status), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -453,7 +471,7 @@ async def _start_checkins_flow(self) -> None: self._set_loading(True) try: data = await asyncio.to_thread(self.client.get_status) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -476,7 +494,7 @@ async def _start_checkin_edit_flow(self) -> None: self._set_loading(True) try: candidates = await asyncio.to_thread(self._load_editable_checkin_candidates) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -519,7 +537,7 @@ async def _select_checkin_to_edit(self, raw_value: str) -> None: self._set_loading(True) try: edit_context = await asyncio.to_thread(self._load_checkin_edit_context, selected) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -566,7 +584,7 @@ async def _select_checkin(self, raw_value: str) -> None: self._set_loading(True) try: questions = await asyncio.to_thread(self._load_checkin_questions, selected) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -624,11 +642,11 @@ async def _answer_checkin_question(self, raw_value: str) -> None: try: await asyncio.to_thread( self.client.complete_checkin, - str(checkin.get("followup_uuid") or ""), + str(checkin.get("followup_uuid") or checkin.get("id") or ""), responses, len(responses) - 1, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -640,7 +658,7 @@ async def _answer_checkin_edit_question(self, raw_value: str) -> None: questions: list[dict[str, Any]] = self.pending_flow["questions"] index: int = int(self.pending_flow["index"]) question: dict[str, Any] = questions[index] - existing_value: Any = self._existing_response_value(index) + existing_value: Any = self._existing_response_value(question, index) answer: Any if raw_value == "": answer = existing_value @@ -670,7 +688,7 @@ async def _answer_checkin_edit_question(self, raw_value: str) -> None: responses, len(responses) - 1, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -685,7 +703,7 @@ async def _start_kudos_menu(self) -> None: asyncio.to_thread(self.client.list_teams), asyncio.to_thread(self._get_current_user_uuid), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -710,7 +728,7 @@ async def _start_kudos_flow(self, intent: KudosIntent) -> None: asyncio.to_thread(self.client.list_teams), asyncio.to_thread(self._get_current_user_uuid), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -892,11 +910,11 @@ async def _set_kudos_value(self, raw_value: str) -> None: async def _confirm_kudos(self, raw_value: str) -> None: assert self.pending_flow is not None - normalized: str = raw_value.strip().lower() - if normalized not in {"1", "yes", "y", "send", "2", "no", "n"}: + decision: bool | None = _parse_confirmation(raw_value, extra_yes=frozenset({"send"})) + if decision is None: self._write_error("Type 1 to send or 2 to cancel.") return - if normalized in {"2", "no", "n"}: + if not decision: self.pending_flow = None self._set_prompt_hint() self._write_system("Kudos cancelled.") @@ -922,7 +940,7 @@ async def _confirm_kudos(self, raw_value: str) -> None: team_uuid_receivers=team_uuids or None, company_value=company_value, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -957,7 +975,7 @@ async def _start_forms_flow(self, action: str | None = None) -> None: self._set_loading(True) try: forms = await asyncio.to_thread(self.client.list_forms, include_questions=True) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1074,7 +1092,7 @@ async def _answer_form_submit_question(self, raw_value: str) -> None: str(form.get("id") or form.get("uuid") or ""), content, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1089,7 +1107,7 @@ async def _start_form_response_picker(self, form: dict[str, Any], *, purpose: st asyncio.to_thread(self.client.get_form, form_uuid), asyncio.to_thread(self.client.list_form_responses, form_uuid), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1128,7 +1146,7 @@ async def _select_form_response(self, raw_value: str) -> None: form_uuid, response_uuid, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1200,7 +1218,7 @@ async def _answer_form_update_question(self, raw_value: str) -> None: str(response.get("id") or response.get("uuid") or ""), content, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1257,7 +1275,7 @@ async def _set_form_transition_note(self, raw_value: str) -> None: to_state, note, ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1280,11 +1298,11 @@ def _start_form_delete_flow(self, form: dict[str, Any], response: dict[str, Any] async def _confirm_form_delete(self, raw_value: str) -> None: assert self.pending_flow is not None - normalized: str = raw_value.strip().lower() - if normalized not in {"1", "yes", "y", "delete", "2", "no", "n"}: + decision: bool | None = _parse_confirmation(raw_value, extra_yes=frozenset({"delete"})) + if decision is None: self._write_error("Type 1 to delete or 2 to cancel.") return - if normalized in {"2", "no", "n"}: + if not decision: self.pending_flow = None self._set_prompt_hint() self._write_system("Delete cancelled.") @@ -1298,7 +1316,7 @@ async def _confirm_form_delete(self, raw_value: str) -> None: self._set_loading(True) try: await asyncio.to_thread(self.client.delete_form_response, form_uuid, response_uuid) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1315,7 +1333,7 @@ async def _load_form_detail(self, form: dict[str, Any]) -> dict[str, Any] | None self._set_loading(True) try: return await asyncio.to_thread(self.client.get_form, form_uuid) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return None finally: @@ -1453,7 +1471,7 @@ async def _show_users(self) -> None: self._set_loading(True) try: users = await asyncio.to_thread(self.client.list_users) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1473,7 +1491,7 @@ async def _show_teams(self) -> None: self._set_loading(True) try: teams = await asyncio.to_thread(self.client.list_teams) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1489,7 +1507,7 @@ async def _start_team_detail_flow(self, query: str = "") -> None: self._set_loading(True) try: teams = await asyncio.to_thread(self.client.list_teams) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1517,7 +1535,7 @@ async def _select_team_detail(self, raw_value: str) -> None: asyncio.to_thread(self.client.get_team, team_uuid), asyncio.to_thread(self.client.list_team_members, team_uuid), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1531,10 +1549,18 @@ async def _select_team_detail(self, raw_value: str) -> None: f"**Members**\n{members_text}" ) + @staticmethod + def _web_base_url(api_url: str) -> str: + """Map the API host to the web app host (api.dailybot.com -> app.dailybot.com). + + Custom/local hosts (no "://api.") are left unchanged. A previous substring + replace of "/api" corrupted the default host into "https:/.dailybot.com". + """ + return api_url.replace("://api.", "://app.").rstrip("/") + def _show_dashboard(self) -> None: - self._write_dailybot( - f"Open your Dailybot dashboard: {self.client.api_url.replace('/api', '').rstrip('/')}/home" - ) + dashboard_url: str = f"{self._web_base_url(self.client.api_url)}/home" + self._write_dailybot(f"Open your Dailybot dashboard: {dashboard_url}") async def _start_mood_flow(self) -> None: self.pending_flow = {"type": "mood_select"} @@ -1557,7 +1583,7 @@ async def _select_mood(self, raw_value: str) -> None: self._set_loading(True) try: await asyncio.to_thread(self.client.track_mood, score) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1568,7 +1594,7 @@ async def _start_checkin_reset_flow(self) -> None: self._set_loading(True) try: candidates = await asyncio.to_thread(self._load_editable_checkin_candidates) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1599,11 +1625,11 @@ async def _select_checkin_to_reset(self, raw_value: str) -> None: async def _confirm_checkin_reset(self, raw_value: str) -> None: assert self.pending_flow is not None - normalized: str = raw_value.strip().lower() - if normalized not in {"1", "yes", "y", "delete", "2", "no", "n"}: + decision: bool | None = _parse_confirmation(raw_value, extra_yes=frozenset({"delete"})) + if decision is None: self._write_error("Type 1 to delete or 2 to cancel.") return - if normalized in {"2", "no", "n"}: + if not decision: self.pending_flow = None self._set_prompt_hint() self._write_system("Check-in delete cancelled.") @@ -1620,7 +1646,7 @@ async def _confirm_checkin_reset(self, raw_value: str) -> None: followup_uuid, response_date=date.today().isoformat(), ) - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1650,15 +1676,15 @@ async def _handle_chat_actions(self, actions: list[dict[str, Any]]) -> None: async def _confirm_terminal_action(self, raw_value: str) -> None: assert self.pending_flow is not None - normalized: str = raw_value.strip().lower() - if normalized not in {"1", "yes", "y", "2", "no", "n"}: + decision: bool | None = _parse_confirmation(raw_value) + if decision is None: self._write_error("Type 1 to start or 2 to skip.") return flow_name: str = str(self.pending_flow.get("flow") or "") params: dict[str, Any] = self.pending_flow.get("params") or {} self.pending_flow = None self._set_prompt_hint() - if normalized in {"2", "no", "n"}: + if not decision: self._write_system("Skipped suggested action.") return await self._start_terminal_flow_by_name(flow_name, params) @@ -1719,7 +1745,7 @@ async def _submit_report(self, message: str) -> None: except httpx.TimeoutException: self._write_error("Dailybot may still be processing the update. Check before retrying.") return - except APIError as exc: + except (APIError, httpx.HTTPError) as exc: self._write_error(self._format_api_error(exc)) return finally: @@ -1791,7 +1817,7 @@ def _ask_current_checkin_edit_question(self) -> None: question: dict[str, Any] = questions[index] prompt: str = self._question_label(question, index) choices: list[str] = self._question_choices(question) - existing_value: Any = self._existing_response_value(index) + existing_value: Any = self._existing_response_value(question, index) existing_label: str = self._format_answer(existing_value) lines: list[str] = [ f"Question {index + 1}/{len(questions)}: {prompt}", @@ -1809,9 +1835,18 @@ def _ask_current_checkin_edit_question(self) -> None: self._set_prompt_hint("Type a replacement, Enter to keep, or `cancel`.") self._write_dailybot("\n".join(lines)) - def _existing_response_value(self, index: int) -> Any: + def _existing_response_value(self, question: dict[str, Any], index: int) -> Any: assert self.pending_flow is not None existing_responses: list[dict[str, Any]] = self.pending_flow.get("existing_responses") or [] + # Prefer matching by question UUID (the API may return answers out of order + # or with a different length than the template questions). + question_uuid: str = str(question.get("uuid") or question.get("id") or "") + if question_uuid: + for response in existing_responses: + response_uuid: str = str(response.get("uuid") or response.get("question_uuid") or "") + if response_uuid == question_uuid: + return response.get("response") + # Fallback: index-aligned (older responses without per-answer UUIDs). if index < len(existing_responses): return existing_responses[index].get("response") return "" @@ -2179,9 +2214,9 @@ async def _load_mention_completion_items(self) -> list[str]: asyncio.to_thread(self.client.list_users), asyncio.to_thread(self.client.list_teams), ) - except APIError as exc: - self._write_error(self._format_api_error(exc)) - self.mention_completion_items = [] + except (APIError, httpx.HTTPError): + # Autocomplete fires on a keystroke — fail silently and do NOT cache the + # failure, so completion recovers once the directory is reachable again. return [] user_items: list[str] = [f"@{self._user_label(user)}" for user in users] team_items: list[str] = [f"@{self._team_label(team)} team" for team in teams] @@ -2275,12 +2310,16 @@ def _extract_user_label(data: dict[str, Any]) -> str | None: return None @staticmethod - def _format_api_error(exc: APIError) -> str: - if exc.status_code in (401, 403): - return "Session expired or missing. Run: dailybot login" - if exc.status_code == 429: - return "Rate limit exceeded. Wait a moment and try again." - return exc.detail + def _format_api_error(exc: APIError | httpx.HTTPError) -> str: + if isinstance(exc, APIError): + if exc.status_code in (401, 403): + return "Session expired or missing. Run: dailybot login" + if exc.status_code == 429: + return "Rate limit exceeded. Wait a moment and try again." + return exc.detail + if isinstance(exc, httpx.TimeoutException): + return "Dailybot took too long to respond. Please try again." + return "Couldn't reach Dailybot. Check your connection and try again." def run_chat_app(client: DailyBotClient) -> None: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c2748e4..42cf9fa 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -43,15 +43,21 @@ Deprecated alias for `dailybot ask` with no message (opens the chat session). Re Starts a Claude-style full-screen Textual chat session. Natural-language turns are sent to Dailybot via `POST /v1/cli/chat/completions/`; the Textual UI is lazy-loaded only when this command runs. -Slash commands are handled locally unless they need an existing CLI endpoint: +Slash commands run **terminal-native flows** locally (interactive numbered prompts / autocomplete) instead of going to the AI; anything else is natural language sent to Dailybot. The chat also recognizes some plain-language intents (e.g. "give kudos to Jane for the release", "show my check-ins") and routes them to the matching native flow — see `dailybot_cli/tui/intents.py`. | Command | Behavior | |---------|----------| -| `/help` | Show chat-mode help. | +| `/help` | Show the command catalog. | | `/clear` | Clear local transcript and start a new terminal session id. | -| `/status` | Call `GET /v1/cli/auth/status/`. | -| `/checkins` | Call `GET /v1/cli/status/`. | -| `/report` | Submit a free-text update through `POST /v1/cli/updates/`. | +| `/status` | Login status + pending check-ins (`GET /v1/cli/auth/status/`, `GET /v1/cli/status/`). | +| `/dashboard` | Show the Dailybot dashboard URL. | +| `/checkins` | Complete pending check-ins with numbered prompts. | +| `/checkin edit` / `/checkin reset` | Edit or delete today's submitted response (`PUT` / `DELETE /v1/checkins//responses/`). | +| `/kudos` | Send kudos to users or teams (`POST /v1/kudos/`). | +| `/forms`, `/form submit\|responses\|update\|transition\|delete` | Full forms lifecycle (`/v1/forms/*`). | +| `/users`, `/teams`, `/team ` | Browse the org directory and teams (`/v1/users/`, `/v1/teams/*`). | +| `/mood` | Track today's mood (`GET` / `POST /v1/mood/track/`). | +| `/report` | Submit a free-text update (`POST /v1/cli/updates/`). | | `/exit` | Leave the chat session. | --- @@ -430,11 +436,19 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | | `POST` | `/v1/checkins//responses/` | `{ responses: [{ uuid, index, response }], last_question_index?, response_date? }` | `{ uuid }` | | +| `GET` | `/v1/checkins/` | — | `{ results: [{ id, name, ... }], next? }` (or bare list) | Paginated; terminal check-in flows | +| `GET` | `/v1/checkins//` | — | `{ ... }` | Check-in detail | +| `GET` | `/v1/templates//` | `?render_special_vars=true&followup_id=` | `{ questions: [...] }` | Question definitions for a check-in | +| `GET` | `/v1/checkins//responses/` | `?date_start&date_end` | `[{ ... }]` | Today's response (edit/reset) | +| `PUT` | `/v1/checkins//responses/` | `{ responses: [...], last_question_index? }` | Updated response | `/checkin edit` | +| `DELETE` | `/v1/checkins//responses/` | `?date_start&date_end` | 204 | `/checkin reset` | +| `GET` | `/v1/mood/track/` | `?date` | `{ ... }` | Read today's mood | +| `POST` | `/v1/mood/track/` | `{ score, date? }` | `{ ... }` | `/mood` | | `GET` | `/v1/users/` | — | `{ results: [{ uuid, full_name }], next: url\|null }` | Paginated | | `GET` | `/v1/teams/` | — | `{ results: [{ uuid, name, active, members_count, is_default }], next? }` | Server-scoped: admins see all, members see own | | `GET` | `/v1/teams//` | — | `{ uuid, name, active, ... }` | Same scoping | | `GET` | `/v1/teams//members/` | — | `[{ uuid, full_name, email }]` | Members of a team the caller can see | -| `POST` | `/v1/kudos/` | `{ content, receivers: [...uuid], company_value? }` | `{ uuid }` | `receivers` merges users+teams; legacy `user_uuid_receivers`/`team_uuid_receivers` still accepted; 406 = daily limit | +| `POST` | `/v1/kudos/` | `{ content, receivers: [...uuid], users_receivers?: [...], teams_receivers?: [...], company_value? }` | `{ uuid }` | `receivers` = users+teams merged (validation); `users_receivers`/`teams_receivers` drive team expansion. Payload contract is being reconciled server-side — see the integration prompt. 406 = daily limit | ### Agent (X-API-KEY *or* Bearer) diff --git a/tests/tui_app_flows_test.py b/tests/tui_app_flows_test.py index 3a0f171..e360171 100644 --- a/tests/tui_app_flows_test.py +++ b/tests/tui_app_flows_test.py @@ -356,3 +356,12 @@ class _FakePrompt: def __init__(self, *, value: str, cursor_position: int) -> None: self.value = value self.cursor_position = cursor_position + + +def test_web_base_url_maps_api_host_to_app_host() -> None: + # Regression: the old `.replace("/api", "")` corrupted the default host. + assert DailybotChatApp._web_base_url("https://api.dailybot.com") == "https://app.dailybot.com" + + +def test_web_base_url_leaves_custom_host_unchanged() -> None: + assert DailybotChatApp._web_base_url("http://djangovscode:8000/") == "http://djangovscode:8000" From 4aeb9bfa6759d1732ef67c059ae7337d357c2911 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 13:13:23 +0000 Subject: [PATCH 10/12] feat(checkin): complete check-in lifecycle in the CLI (status/show/history/edit/reset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes check-ins — Dailybot's flagship feature — first-class in the CLI, headless and interactive, on top of the server's new check-in endpoints + auth parity. New commands (all --json for agents/CI, all accept API key or login): - checkin status [--date] — pending/completed state per check-in for a day - checkin show — configuration + question definitions (types, schedule) - checkin history — response history (--days N or --from/--to) - checkin edit — edit an existing response (-a overrides or prompts) - checkin reset — delete/reset your response for a day (confirmed) Plus: - list_checkins() now sends date/include_summary/include_pending_users params. - 8 check-in response-lifecycle error codes mapped to friendly messages (backfill/future/trigger-time/inactive/not-a-member/version-conflict/bad-date). - Backfill + future-dating via --response-date / --date across the commands. - Display helpers for status, detail, and history; docs in API_REFERENCE. - 8 new tests (commands + client params). Follow-up (not in this commit): conditional-branching answer flow (next_question_action), richer TUI widgets, checkin update/skip/auto. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/api_client.py | 18 +- dailybot_cli/commands/checkin.py | 137 ++++++++++++- dailybot_cli/commands/public_api_helpers.py | 19 ++ dailybot_cli/commands/user_scoped_actions.py | 202 +++++++++++++++++++ dailybot_cli/display.py | 85 ++++++++ docs/API_REFERENCE.md | 22 ++ tests/api_client_test.py | 27 +++ tests/public_api_commands_test.py | 100 +++++++++ 8 files changed, 607 insertions(+), 3 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index cf8c342..bfb0ffc 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -250,15 +250,29 @@ def complete_checkin( ) return self._handle_response(response) - def list_checkins(self) -> list[dict[str, Any]]: - """GET /v1/checkins/ — fetch visible check-ins.""" + def list_checkins( + self, + *, + date: str | None = None, + include_summary: bool = False, + include_pending_users: bool = False, + ) -> list[dict[str, Any]]: + """GET /v1/checkins/ — fetch visible check-ins with optional completion state.""" results: list[dict[str, Any]] = [] + params: dict[str, str] = {} + if date: + params["date"] = date + if include_summary: + params["include_summary"] = "true" + if include_pending_users: + params["include_pending_users"] = "true" url: str | None = f"{self.api_url}/v1/checkins/" pages_fetched: int = 0 while url is not None and pages_fetched < _MAX_LIST_PAGES: response: httpx.Response = httpx.get( url, headers=self._headers(), + params=params if pages_fetched == 0 else None, timeout=self.timeout, ) if response.status_code >= 400: diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index d830490..9e4e258 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -1,9 +1,19 @@ """Check-in commands for the user-scoped public API.""" +import sys + import click from dailybot_cli.commands.public_api_helpers import require_auth -from dailybot_cli.commands.user_scoped_actions import execute_checkin_complete, execute_checkin_list +from dailybot_cli.commands.user_scoped_actions import ( + execute_checkin_complete, + execute_checkin_edit, + execute_checkin_history, + execute_checkin_list, + execute_checkin_reset, + execute_checkin_show, + execute_checkin_status, +) _HELP: str = "Acts as you. You can only see and act on what you could in the webapp." @@ -76,3 +86,128 @@ def checkin_complete( assume_yes=assume_yes, json_mode=json_mode, ) + + +@checkin.command("status") +@click.option("--date", "date", default=None, help="Target a specific day (YYYY-MM-DD). Defaults to today.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_status(date: str | None, json_mode: bool) -> None: + """Show pending/completed status for your check-ins on a date. + + \b + Examples: + dailybot checkin status + dailybot checkin status --date 2026-07-01 --json + """ + client = require_auth() + execute_checkin_status(client, date=date, json_mode=json_mode) + + +@checkin.command("show") +@click.argument("followup_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_show(followup_uuid: str, json_mode: bool) -> None: + """Show a check-in's configuration and questions. + + \b + Examples: + dailybot checkin show + dailybot checkin show --json + """ + client = require_auth() + execute_checkin_show(client, followup_uuid, json_mode=json_mode) + + +@checkin.command("history") +@click.argument("followup_uuid") +@click.option("--days", type=int, default=None, help="Look back N days from today.") +@click.option("--from", "date_from", default=None, help="Range start (YYYY-MM-DD).") +@click.option("--to", "date_to", default=None, help="Range end (YYYY-MM-DD).") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_history( + followup_uuid: str, + days: int | None, + date_from: str | None, + date_to: str | None, + json_mode: bool, +) -> None: + """Show your response history for a check-in. + + \b + Examples: + dailybot checkin history --days 7 + dailybot checkin history --from 2026-06-01 --to 2026-06-30 --json + """ + client = require_auth() + execute_checkin_history( + client, + followup_uuid, + days=days, + date_from=date_from, + date_to=date_to, + json_mode=json_mode, + ) + + +@checkin.command("reset") +@click.argument("followup_uuid") +@click.option("--date", "response_date", default=None, help="Target a specific day (YYYY-MM-DD).") +@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_reset( + followup_uuid: str, + response_date: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Delete (reset) your check-in response for a day. + + \b + Examples: + dailybot checkin reset + dailybot checkin reset --date 2026-07-01 --yes + """ + client = require_auth() + execute_checkin_reset( + client, + followup_uuid, + response_date=response_date, + assume_yes=assume_yes, + json_mode=json_mode, + ) + + +@checkin.command("edit") +@click.argument("followup_uuid") +@click.option("--answer", "-a", multiple=True, help='Override an answer as "index=response" (0-based).') +@click.option("--date", "response_date", default=None, help="Target a specific day (YYYY-MM-DD).") +@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_edit( + followup_uuid: str, + answer: tuple[str, ...], + response_date: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Edit an existing check-in response. + + \b + With -a flags (or when a terminal is attached, prompts per question showing + the current answer as the default). + + \b + Examples: + dailybot checkin edit -a 0="Updated answer" --yes + dailybot checkin edit # prompts each question + """ + client = require_auth() + execute_checkin_edit( + client, + followup_uuid, + answer_flags=answer, + response_date=response_date, + assume_yes=assume_yes, + json_mode=json_mode, + interactive=(not answer and not json_mode and sys.stdin.isatty()), + ) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 7ea34b5..5dd09eb 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -50,6 +50,25 @@ "Empty receiver set — `--to` or `--team` must resolve to at least one valid receiver." ), "no_users_found": "Some users not found or duplicated.", + # Check-in response lifecycle + "user_is_not_a_followup_member": "You're not a participant in this check-in.", + "responses_not_allowed_on_inactive_followup": ( + "This check-in is inactive — responses aren't allowed." + ), + "previous_responses_are_not_allowed": ( + "Backfilling past responses is disabled for this check-in." + ), + "future_responses_are_not_allowed": ( + "Future-dated responses are disabled for this check-in." + ), + "followup_not_allow_responses_before_trigger_time": ( + "It's too early — this check-in isn't open yet (before its trigger time)." + ), + "not_valid_followup_uuid": "Invalid check-in UUID.", + "template_questions_version_conflict": ( + "The check-in's questions changed since you fetched them. Re-run and try again." + ), + "response_date_format_is_invalid": "Invalid date. Use YYYY-MM-DD.", } diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 1a7b55d..0a85a9e 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -1,6 +1,7 @@ """Shared handlers for user-scoped public API commands and interactive mode.""" import json +from datetime import date as date_cls, timedelta from typing import Any import click @@ -20,12 +21,16 @@ from dailybot_cli.display import ( console, print_checkin_complete_result, + print_checkin_detail, + print_checkin_history_table, print_checkin_list_overview, + print_checkin_status_table, print_error, print_form_submit_result, print_forms_table, print_info, print_pending_checkins, + print_success, print_users_table, ) @@ -535,3 +540,200 @@ def execute_user_list( print_users_table(users) return users + + +def _template_uuid(checkin: dict[str, Any]) -> str: + """Best-effort template UUID from a check-in detail payload.""" + template: Any = checkin.get("template") + if isinstance(template, dict): + return str(template.get("uuid") or template.get("id") or "") + return str(checkin.get("template_uuid") or template or "") + + +def execute_checkin_status( + client: DailyBotClient, + *, + date: str | None = None, + json_mode: bool = False, +) -> None: + """Show pending/completed state for check-ins on a date (default today).""" + try: + with console.status("Fetching check-ins..."): + checkins: list[dict[str, Any]] = client.list_checkins(date=date, include_summary=True) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"date": date or "today", "count": len(checkins), "checkins": checkins}) + return + print_checkin_status_table(checkins, date_label=date or "today") + + +def execute_checkin_show( + client: DailyBotClient, + followup_uuid: str, + *, + json_mode: bool = False, +) -> None: + """Show a check-in's configuration and question definitions.""" + try: + with console.status("Loading check-in..."): + checkin: dict[str, Any] = client.get_checkin(followup_uuid) + questions: list[dict[str, Any]] = [] + template_uuid: str = _template_uuid(checkin) + if template_uuid: + template: dict[str, Any] = client.get_template( + template_uuid, followup_uuid=followup_uuid + ) + raw_questions: Any = template.get("questions") or template.get("template_questions") + if isinstance(raw_questions, list): + questions = raw_questions + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"checkin": checkin, "questions": questions}) + return + print_checkin_detail(checkin, questions) + + +def execute_checkin_history( + client: DailyBotClient, + followup_uuid: str, + *, + days: int | None = None, + date_from: str | None = None, + date_to: str | None = None, + json_mode: bool = False, +) -> None: + """Show a check-in's response history over a date range.""" + date_start: str | None = date_from + date_end: str | None = date_to + if days is not None: + today: date_cls = date_cls.today() + date_end = today.isoformat() + date_start = (today - timedelta(days=max(days - 1, 0))).isoformat() + try: + with console.status("Fetching response history..."): + responses: list[dict[str, Any]] = client.list_checkin_responses( + followup_uuid, date_start=date_start, date_end=date_end + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json( + { + "count": len(responses), + "date_start": date_start, + "date_end": date_end, + "responses": responses, + } + ) + return + print_checkin_history_table(responses) + + +def execute_checkin_reset( + client: DailyBotClient, + followup_uuid: str, + *, + response_date: str | None = None, + assume_yes: bool = False, + json_mode: bool = False, +) -> None: + """Delete (reset) the caller's own check-in response for a date.""" + target: str = response_date or "today" + if not json_mode: + confirm_write([f"Delete your check-in response for {target}? This cannot be undone."], assume_yes) + try: + with console.status("Deleting response..."): + result: dict[str, Any] = client.delete_checkin_response( + followup_uuid, response_date=response_date + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result if isinstance(result, dict) else {"deleted": True}) + return + deleted_count: Any = result.get("deleted_count") if isinstance(result, dict) else None + if deleted_count: + print_success(f"Deleted {deleted_count} response(s) for {target}.") + else: + print_success(f"Reset check-in response for {target}.") + + +def execute_checkin_edit( + client: DailyBotClient, + followup_uuid: str, + *, + answer_flags: tuple[str, ...] = (), + response_date: str | None = None, + assume_yes: bool = False, + json_mode: bool = False, + interactive: bool = False, +) -> None: + """Edit an existing check-in response (override answers, then PUT).""" + try: + with console.status("Loading current response..."): + existing: list[dict[str, Any]] = client.list_checkin_responses( + followup_uuid, date_start=response_date, date_end=response_date + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if not existing: + message: str = "No existing response to edit. Use `dailybot checkin complete` to create one." + if json_mode: + emit_json({"error": message, "status": 0}) + else: + print_error(message) + raise SystemExit(EXIT_USAGE_ERROR) + + answers: list[dict[str, Any]] = existing[0].get("responses") or [] + try: + overrides: dict[int, str] = parse_answer_flags(answer_flags) + except ValueError as exc: + if json_mode: + emit_json({"error": str(exc), "status": 0}) + else: + print_error(str(exc)) + raise SystemExit(EXIT_USAGE_ERROR) from exc + + new_responses: list[dict[str, Any]] = [] + for index, answer in enumerate(answers): + current: Any = answer.get("response") + if index in overrides: + value: Any = overrides[index] + elif interactive and not json_mode: + prompt: str = str(answer.get("question") or f"Question {index}") + reply: str | None = questionary.text(f"{prompt}", default=str(current or "")).ask() + if reply is None: + raise InteractiveAbort() + value = reply + else: + value = current + new_responses.append( + {"uuid": answer.get("uuid"), "index": index, "response": value} + ) + + if not new_responses: + print_error("This response has no answers to edit.") + raise SystemExit(EXIT_USAGE_ERROR) + + if not json_mode and not interactive: + confirm_write([f"Update your check-in response for {response_date or 'today'}?"], assume_yes) + + try: + with console.status("Updating response..."): + result: dict[str, Any] = client.update_checkin_response( + followup_uuid, new_responses, last_question_index=len(new_responses) - 1 + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result if isinstance(result, dict) else {"updated": True}) + return + print_success(f"Updated check-in response for {response_date or 'today'}.") diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 97a97c4..4dcbb04 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -453,6 +453,91 @@ def print_checkin_complete_result(followup_name: str, data: dict[str, Any]) -> N print_info(f"Response ID: {response_id}") +def _checkin_uuid(checkin: dict[str, Any]) -> str: + """Best-effort check-in identifier (the API mixes uuid / followup_uuid / id).""" + return str(checkin.get("uuid") or checkin.get("followup_uuid") or checkin.get("id") or "") + + +def _checkin_name(checkin: dict[str, Any]) -> str: + return str(checkin.get("name") or checkin.get("followup_name") or "Check-in") + + +def print_checkin_status_table(checkins: list[dict[str, Any]], *, date_label: str) -> None: + """Display each check-in with its pending/completed state for a date.""" + if not checkins: + print_info(f"No check-ins for {date_label}.") + return + table: Table = Table(title=f"Check-ins — {date_label}", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("Status") + table.add_column("Followup UUID", style="dim") + table.add_column("Questions", justify="right") + for checkin in checkins: + completed: bool = bool( + checkin.get("response_completed") + or checkin.get("is_completed") + or checkin.get("completed") + ) + status: str = "[green]completed[/green]" if completed else "[yellow]pending[/yellow]" + questions: list[Any] = checkin.get("template_questions") or checkin.get("questions") or [] + table.add_row( + _checkin_name(checkin), + status, + _checkin_uuid(checkin), + str(len(questions)) if questions else "—", + ) + console.print(table) + + +def print_checkin_detail(checkin: dict[str, Any], questions: list[dict[str, Any]]) -> None: + """Display a check-in's configuration and question definitions.""" + lines: list[str] = [f"[bold]{_checkin_name(checkin)}[/bold]", f"UUID: {_checkin_uuid(checkin)}"] + schedule: dict[str, Any] = checkin.get("schedule") or {} + for label, key in (("Frequency", "frequency"), ("Time", "time"), ("Timezone", "timezone")): + value: Any = checkin.get(key) or schedule.get(key) + if value: + lines.append(f"{label}: {value}") + console.print(Panel("\n".join(lines), title="Check-in", border_style="cyan")) + if not questions: + print_info("No questions defined for this check-in.") + return + table: Table = Table(title="Questions", border_style="cyan") + table.add_column("#", justify="right") + table.add_column("Question", style="bold") + table.add_column("Type") + table.add_column("Question UUID", style="dim") + for index, question in enumerate(questions): + table.add_row( + str(index), + str(question.get("question") or question.get("text") or ""), + str(question.get("question_type") or question.get("type") or "text"), + str(question.get("uuid") or question.get("id") or ""), + ) + console.print(table) + + +def print_checkin_history_table(responses: list[dict[str, Any]]) -> None: + """Display a check-in's response history over a date range.""" + if not responses: + print_info("No responses found in that range.") + return + table: Table = Table(title=f"Response history ({len(responses)})", border_style="cyan") + table.add_column("Date") + table.add_column("Completed") + table.add_column("Answers") + for response in responses: + raw_date: str = str(response.get("response_date") or "") + if not raw_date: + raw_date = str(response.get("created_at") or "")[:10] + completed: str = "yes" if response.get("response_completed") else "no" + answers: list[dict[str, Any]] = response.get("responses") or [] + summary: str = "; ".join( + str(answer.get("response") or "") for answer in answers if answer.get("response") + ) + table.add_row(raw_date or "—", completed, (summary[:80] or "—")) + console.print(table) + + def print_forms_table(forms: list[dict[str, Any]]) -> None: """Display visible forms in a table.""" if not forms: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 42cf9fa..255d315 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -129,6 +129,28 @@ Completes a pending check-in. Interactive path: prompts each question using type-aware inputs (text, numeric, boolean, choice). Non-interactive path requires all `--answer` flags matching the question count. +#### `dailybot checkin status [--date YYYY-MM-DD] [--json]` + +Shows each check-in with its pending/completed state for a date (default today). Calls `GET /v1/checkins/?date=...&include_summary=true`. + +#### `dailybot checkin show [--json]` + +Introspects a check-in's configuration and question definitions. Calls `GET /v1/checkins//` + `GET /v1/templates//?render_special_vars=true&followup_id=`. + +#### `dailybot checkin history [--days N | --from YYYY-MM-DD --to YYYY-MM-DD] [--json]` + +Lists your response history for a check-in over a date range. Calls `GET /v1/checkins//responses/?date_start=...&date_end=...`. + +#### `dailybot checkin edit [-a index=response]... [--date YYYY-MM-DD] [--yes] [--json]` + +Edits an existing response: fetches it (`GET .../responses/`), applies `-a` overrides (or prompts each question with the current answer as default when a terminal is attached), then `PUT /v1/checkins//responses/`. + +#### `dailybot checkin reset [--date YYYY-MM-DD] [--yes] [--json]` + +Deletes (resets) your own response for a day via `DELETE /v1/checkins//responses/`. Confirms first unless `--yes` (or `--json`). + +> **Backfill / future-dating:** `complete` (`--response-date`), `edit`/`reset`/`history` (`--date` / range) target other days. The server may reject with `previous_responses_are_not_allowed` / `future_responses_are_not_allowed` / `followup_not_allow_responses_before_trigger_time` if the check-in disallows it — the CLI maps these `code`s to friendly messages. + --- ### `dailybot form` (group) — user-scoped, Bearer or API key auth diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 9556793..b413578 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -790,3 +790,30 @@ def test_headers_unauthenticated_sends_neither(self) -> None: headers = client._headers(authenticated=False) assert "Authorization" not in headers assert "X-API-KEY" not in headers + + +class TestDailyBotClientCheckinsExtended: + def test_list_checkins_sends_summary_params(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [], "next": None} + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_checkins(date="2026-07-01", include_summary=True) + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"] == {"date": "2026-07-01", "include_summary": "true"} + + def test_delete_checkin_response_uses_date_range(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"deleted": True, "deleted_count": 1} + + with patch("httpx.request", return_value=mock_response) as mock_request: + result: dict[str, Any] = client.delete_checkin_response( + "followup-uuid", response_date="2026-07-01" + ) + + call_kwargs: dict[str, Any] = mock_request.call_args[1] + assert call_kwargs["params"] == {"date_start": "2026-07-01", "date_end": "2026-07-01"} + assert result["deleted_count"] == 1 diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index b03d2cf..655f575 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -1097,3 +1097,103 @@ def test_returns_none_when_auth_status_rejects_api_key(self) -> None: client: MagicMock = MagicMock() client.auth_status.side_effect = APIError(401, "Authentication credentials were not provided.") assert get_current_user_uuid(client) is None + + +class TestCheckinExtendedCommands: + def _client(self, mock_client_cls: MagicMock, mock_get_auth: MagicMock) -> MagicMock: + mock_get_auth.return_value = "tok" + return mock_client_cls.return_value + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_status_json( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.list_checkins.return_value = [ + {"uuid": "f1", "name": "Standup", "response_completed": False, "template_questions": [{}]} + ] + result = runner.invoke(cli, ["checkin", "status", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["count"] == 1 + client.list_checkins.assert_called_once_with(date=None, include_summary=True) + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_show_json( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.get_checkin.return_value = {"uuid": "f1", "name": "Standup", "template": {"uuid": "t1"}} + client.get_template.return_value = { + "questions": [{"uuid": "q1", "question": "Done?", "question_type": "text_field"}] + } + result = runner.invoke(cli, ["checkin", "show", "f1", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["questions"][0]["uuid"] == "q1" + client.get_template.assert_called_once_with("t1", followup_uuid="f1") + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_history_days_json( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.list_checkin_responses.return_value = [ + {"response_date": "2026-07-01", "response_completed": True, "responses": []} + ] + result = runner.invoke(cli, ["checkin", "history", "f1", "--days", "7", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["count"] == 1 + client.list_checkin_responses.assert_called_once() + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_reset_json_skips_confirm( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.delete_checkin_response.return_value = {"deleted": True, "deleted_count": 1} + result = runner.invoke(cli, ["checkin", "reset", "f1", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["deleted_count"] == 1 + client.delete_checkin_response.assert_called_once_with("f1", response_date=None) + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_edit_overrides_answer( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.list_checkin_responses.return_value = [ + { + "responses": [ + {"uuid": "q1", "index": 0, "response": "old"}, + {"uuid": "q2", "index": 1, "response": "keep"}, + ] + } + ] + client.update_checkin_response.return_value = {"uuid": "r1"} + result = runner.invoke(cli, ["checkin", "edit", "f1", "-a", "0=new", "--json"]) + assert result.exit_code == 0 + new_responses: list[dict[str, Any]] = client.update_checkin_response.call_args.args[1] + assert new_responses[0]["response"] == "new" + assert new_responses[1]["response"] == "keep" + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_reset_maps_backfill_error_code( + self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner + ) -> None: + client: MagicMock = self._client(mock_client_cls, mock_get_auth) + client.delete_checkin_response.side_effect = APIError( + 409, "nope", code="previous_responses_are_not_allowed" + ) + result = runner.invoke(cli, ["checkin", "reset", "f1", "--json"]) + payload: dict[str, Any] = json.loads(result.output) + assert payload["status"] == 409 + assert payload["code"] == "previous_responses_are_not_allowed" From e1d9262420bb1d08e5239d5ffa12b951baa7be93 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 13:18:07 +0000 Subject: [PATCH 11/12] fix(checkin): read template questions from the nested 'fields' object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The templates endpoint returns questions as {"questions": {"fields": [...]}}, not a flat list — so 'dailybot checkin show' now surfaces the questions instead of 'No questions defined'. Verified live against the server. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/commands/user_scoped_actions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 0a85a9e..fc83a0d 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -586,6 +586,9 @@ def execute_checkin_show( template_uuid, followup_uuid=followup_uuid ) raw_questions: Any = template.get("questions") or template.get("template_questions") + # The template endpoint nests questions as {"fields": [...]}. + if isinstance(raw_questions, dict): + raw_questions = raw_questions.get("fields") if isinstance(raw_questions, list): questions = raw_questions except APIError as exc: From cb8c339b7133bc116f2ba28f5ea5e6a2f54071e4 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Fri, 3 Jul 2026 13:48:22 +0000 Subject: [PATCH 12/12] style: apply ruff format to check-in commands and helpers Line-wrapping/formatting only (no behavior change) on the files touched by the check-in lifecycle + TUI-hardening work. Co-Authored-By: Claude Opus 4.8 (1M context) --- dailybot_cli/commands/checkin.py | 8 ++++++-- dailybot_cli/commands/public_api_helpers.py | 4 +--- dailybot_cli/commands/user_scoped_actions.py | 16 ++++++++++------ dailybot_cli/tui/app.py | 8 ++++---- tests/commands_test.py | 8 ++------ tests/public_api_commands_test.py | 17 ++++++++++++++--- 6 files changed, 37 insertions(+), 24 deletions(-) diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 9e4e258..5c15d2b 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -89,7 +89,9 @@ def checkin_complete( @checkin.command("status") -@click.option("--date", "date", default=None, help="Target a specific day (YYYY-MM-DD). Defaults to today.") +@click.option( + "--date", "date", default=None, help="Target a specific day (YYYY-MM-DD). Defaults to today." +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def checkin_status(date: str | None, json_mode: bool) -> None: """Show pending/completed status for your check-ins on a date. @@ -179,7 +181,9 @@ def checkin_reset( @checkin.command("edit") @click.argument("followup_uuid") -@click.option("--answer", "-a", multiple=True, help='Override an answer as "index=response" (0-based).') +@click.option( + "--answer", "-a", multiple=True, help='Override an answer as "index=response" (0-based).' +) @click.option("--date", "response_date", default=None, help="Target a specific day (YYYY-MM-DD).") @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 5dd09eb..7a94a41 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -58,9 +58,7 @@ "previous_responses_are_not_allowed": ( "Backfilling past responses is disabled for this check-in." ), - "future_responses_are_not_allowed": ( - "Future-dated responses are disabled for this check-in." - ), + "future_responses_are_not_allowed": ("Future-dated responses are disabled for this check-in."), "followup_not_allow_responses_before_trigger_time": ( "It's too early — this check-in isn't open yet (before its trigger time)." ), diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index fc83a0d..6a4e818 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -648,7 +648,9 @@ def execute_checkin_reset( """Delete (reset) the caller's own check-in response for a date.""" target: str = response_date or "today" if not json_mode: - confirm_write([f"Delete your check-in response for {target}? This cannot be undone."], assume_yes) + confirm_write( + [f"Delete your check-in response for {target}? This cannot be undone."], assume_yes + ) try: with console.status("Deleting response..."): result: dict[str, Any] = client.delete_checkin_response( @@ -687,7 +689,9 @@ def execute_checkin_edit( exit_for_api_error(exc, json_mode) if not existing: - message: str = "No existing response to edit. Use `dailybot checkin complete` to create one." + message: str = ( + "No existing response to edit. Use `dailybot checkin complete` to create one." + ) if json_mode: emit_json({"error": message, "status": 0}) else: @@ -717,16 +721,16 @@ def execute_checkin_edit( value = reply else: value = current - new_responses.append( - {"uuid": answer.get("uuid"), "index": index, "response": value} - ) + new_responses.append({"uuid": answer.get("uuid"), "index": index, "response": value}) if not new_responses: print_error("This response has no answers to edit.") raise SystemExit(EXIT_USAGE_ERROR) if not json_mode and not interactive: - confirm_write([f"Update your check-in response for {response_date or 'today'}?"], assume_yes) + confirm_write( + [f"Update your check-in response for {response_date or 'today'}?"], assume_yes + ) try: with console.status("Updating response..."): diff --git a/dailybot_cli/tui/app.py b/dailybot_cli/tui/app.py index eca2a58..929ef8d 100644 --- a/dailybot_cli/tui/app.py +++ b/dailybot_cli/tui/app.py @@ -212,9 +212,7 @@ def on_key(self, event: Key) -> None: if event.key in {"shift+tab", "shift_tab", "backtab"}: event.prevent_default() event.stop() - self.run_worker( - self._complete_prompt(reverse=True), exclusive=True, group="completion" - ) + self.run_worker(self._complete_prompt(reverse=True), exclusive=True, group="completion") async def on_input_submitted(self, event: Input.Submitted) -> None: prompt = self.query_one("#prompt", Input) @@ -1843,7 +1841,9 @@ def _existing_response_value(self, question: dict[str, Any], index: int) -> Any: question_uuid: str = str(question.get("uuid") or question.get("id") or "") if question_uuid: for response in existing_responses: - response_uuid: str = str(response.get("uuid") or response.get("question_uuid") or "") + response_uuid: str = str( + response.get("uuid") or response.get("question_uuid") or "" + ) if response_uuid == question_uuid: return response.get("response") # Fallback: index-aligned (older responses without per-answer UUIDs). diff --git a/tests/commands_test.py b/tests/commands_test.py index 0cbcf8c..54b8e53 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -962,9 +962,7 @@ def test_status_works_with_api_key( assert result.exit_code == 0 @patch("dailybot_cli.commands.status.get_agent_auth") - def test_status_not_authenticated( - self, mock_get_auth: MagicMock, runner: CliRunner - ) -> None: + def test_status_not_authenticated(self, mock_get_auth: MagicMock, runner: CliRunner) -> None: mock_get_auth.return_value = None result = runner.invoke(cli, ["status"]) assert result.exit_code == 1 @@ -1166,9 +1164,7 @@ def test_ask_without_message_launches_tui( mock_client.create_chat_completion.assert_not_called() @patch("dailybot_cli.commands.ask.require_auth") - def test_ask_reads_piped_stdin( - self, mock_require_auth: MagicMock, runner: CliRunner - ) -> None: + def test_ask_reads_piped_stdin(self, mock_require_auth: MagicMock, runner: CliRunner) -> None: mock_client: MagicMock = MagicMock() mock_client.create_chat_completion.return_value = {"message": {"content": "ok"}} mock_require_auth.return_value = mock_client diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 655f575..f3bf5ef 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -1095,7 +1095,9 @@ def test_returns_none_when_auth_status_rejects_api_key(self) -> None: from dailybot_cli.commands.public_api_helpers import get_current_user_uuid client: MagicMock = MagicMock() - client.auth_status.side_effect = APIError(401, "Authentication credentials were not provided.") + client.auth_status.side_effect = APIError( + 401, "Authentication credentials were not provided." + ) assert get_current_user_uuid(client) is None @@ -1111,7 +1113,12 @@ def test_checkin_status_json( ) -> None: client: MagicMock = self._client(mock_client_cls, mock_get_auth) client.list_checkins.return_value = [ - {"uuid": "f1", "name": "Standup", "response_completed": False, "template_questions": [{}]} + { + "uuid": "f1", + "name": "Standup", + "response_completed": False, + "template_questions": [{}], + } ] result = runner.invoke(cli, ["checkin", "status", "--json"]) assert result.exit_code == 0 @@ -1125,7 +1132,11 @@ def test_checkin_show_json( self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner ) -> None: client: MagicMock = self._client(mock_client_cls, mock_get_auth) - client.get_checkin.return_value = {"uuid": "f1", "name": "Standup", "template": {"uuid": "t1"}} + client.get_checkin.return_value = { + "uuid": "f1", + "name": "Standup", + "template": {"uuid": "t1"}, + } client.get_template.return_value = { "questions": [{"uuid": "q1", "question": "Done?", "question_type": "text_field"}] }