diff --git a/README.md b/README.md index 24ebbcf..fc08837 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Same auto-detection as `upgrade`: the matching uninstall is run for you on `pipx ## For humans -Authenticate once with your Dailybot email, then submit updates and check pending check-ins right from your terminal. +Authenticate once with your Dailybot email, then submit updates, complete check-ins, fill out forms, give kudos, and browse your team — all from your terminal. ```bash # Log in (interactive, email OTP) @@ -91,7 +91,139 @@ dailybot update "Finished the auth module, starting on tests." dailybot update --done "Auth module" --doing "Tests" --blocked "None" ``` -Run `dailybot` with no arguments to enter **interactive mode** — if you're not logged in yet, it will walk you through authentication first, then let you submit updates step by step. +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. + +--- + +## Check-ins + +```bash +# List today's pending check-ins +dailybot checkin list + +# Complete a check-in interactively (prompts each question) +dailybot checkin complete + +# Complete non-interactively with answer flags (0-based index) +dailybot checkin complete \ + -a 0="Shipped the auth refactor" \ + -a 1="Reviewing the migration plan" \ + --yes + +# Target a specific response date +dailybot checkin complete -a 0="Done" --response-date 2026-05-20 --yes + +# Machine-readable JSON output +dailybot checkin list --json +dailybot checkin complete -a 0="Done" --yes --json +``` + +### `dailybot checkin complete` options + +| Flag | Short | Description | +|------|-------|-------------| +| `--answer` | `-a` | Answer as `index=response` (0-based). Repeatable. Prompts when omitted. | +| `--response-date` | | Target date `YYYY-MM-DD`. Defaults to today. | +| `--yes` | `-y` | Skip the confirmation prompt. | +| `--json` | | Emit machine-readable JSON to stdout. | + +--- + +## Forms + +```bash +# List all forms visible to you (includes question count) +dailybot form list + +# Submit a form — guided mode (prompts each question by label and type) +dailybot form submit + +# Submit non-interactively with a JSON content map +dailybot form submit \ + --content '{"":"Great week!", "":"No blockers"}' \ + --yes + +# Machine-readable JSON output +dailybot form list --json +dailybot form submit --content '{"":"Yes"}' --yes --json +``` + +Guided mode (`form submit` without `--content`) fetches the form's question list from the API and prompts each question one by one, with type-aware inputs: + +| Question type | Prompt | +|--------------|--------| +| `text_field` | Free-text input | +| `numeric` | Number input, validated | +| `boolean` | Yes / No selector | +| `choice` | Pick from a list of options | + +### `dailybot form submit` options + +| Flag | Short | Description | +|------|-------|-------------| +| `--content` | `-c` | JSON map of `{"": ""}`. Prompts when omitted. | +| `--yes` | `-y` | Skip the confirmation prompt. | +| `--json` | | Emit machine-readable JSON to stdout. | + +--- + +## Kudos + +```bash +# Give kudos — receiver resolved by full name against your org directory +dailybot kudos give --to "Jane Doe" --message "Shipped the auth refactor cleanly, great work!" + +# Resolve by UUID instead +dailybot kudos give --to --message "Thanks for the PR review." --yes + +# Attach a company value +dailybot kudos give --to "Jane Doe" --message "Great!" --value --yes + +# Machine-readable +dailybot kudos give --to "Jane Doe" --message "Great!" --yes --json +``` + +If `--to` matches more than one name partially, the CLI lists the ambiguous matches and exits — it never guesses. Pass the full name or a UUID to be precise. + +### `dailybot kudos give` options + +| Flag | Short | Description | +|------|-------|-------------| +| `--to` | `-t` | Receiver full name or UUID. Required. | +| `--message` | `-m` | Kudos message (team-visible). Required. | +| `--value` | | Optional company value UUID. | +| `--yes` | `-y` | Skip the confirmation prompt. | +| `--json` | | Emit machine-readable JSON to stdout. | + +--- + +## Team + +```bash +# List all members in your organization +dailybot user list + +# Machine-readable +dailybot user list --json +``` + +The table shows **Name** and **User UUID**. You can copy a UUID directly into `dailybot kudos give --to ` for precise targeting. + +--- + +## User-scoped exit codes + +All user-scoped commands (`checkin`, `form`, `kudos`, `user`) use structured exit codes for scripting: + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `2` | Invalid input (bad format, ambiguous receiver) | +| `3` | Not logged in — run `dailybot login` | +| `4` | Permission denied (403), self-kudos, or daily kudos limit reached | +| `5` | Form response quota exhausted | +| `6` | Rate limited — wait and retry | +| `7` | User declined the confirmation prompt | ## For agents @@ -309,13 +441,49 @@ Replies to agent emails land as messages retrievable via `dailybot agent message ## Commands +### Session + | Command | Description | -|---|---| +|---------|-------------| | `dailybot login` | Authenticate with email OTP | | `dailybot logout` | Log out and revoke token | -| `dailybot status` | Show pending check-ins for today | -| `dailybot update` | Submit a check-in update (free-text or structured) | +| `dailybot status` | Show pending check-ins and auth status | +| `dailybot update` | Submit a free-text or structured check-in update | | `dailybot config` | Get, set, or remove a stored setting (e.g. API key) | +| `dailybot version` | Show version info and optionally check for updates | +| `dailybot upgrade` | Upgrade the CLI (auto-detects install method) | +| `dailybot uninstall` | Remove the CLI | + +### Check-ins + +| Command | Description | +|---------|-------------| +| `dailybot checkin list` | List today's pending check-ins | +| `dailybot checkin complete ` | Complete a pending check-in (interactive or `-a` flags) | + +### Forms + +| Command | Description | +|---------|-------------| +| `dailybot form list` | List forms visible to you (includes question count) | +| `dailybot form submit ` | Submit a form (guided prompts or `--content` JSON) | + +### Kudos + +| Command | Description | +|---------|-------------| +| `dailybot kudos give` | Give kudos to a teammate by name or UUID | + +### Team + +| Command | Description | +|---------|-------------| +| `dailybot user list` | List all members in your organization | + +### Agent commands + +| Command | Description | +|---------|-------------| | `dailybot agent configure` | Configure a named agent profile | | `dailybot agent profiles` | List all configured agent profiles | | `dailybot agent register` | Register a new agent and organization (standalone) | diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 938c8ad..c4a12d9 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -6,6 +6,8 @@ from dailybot_cli.config import get_api_key, get_api_url, get_token +_MAX_LIST_PAGES: int = 50 # safety cap for paginated list endpoints + class APIError(Exception): """Raised when the API returns a non-success response.""" @@ -157,6 +159,105 @@ def get_status(self) -> dict[str, Any]: ) return self._handle_response(response) + # --- User-scoped public API endpoints (Bearer token) --- + + def complete_checkin( + self, + followup_uuid: str, + responses: list[dict[str, Any]], + last_question_index: int | None = None, + response_date: str | None = None, + ) -> dict[str, Any]: + """POST /v1/checkins//responses/""" + payload: dict[str, Any] = {"responses": responses} + if last_question_index is not None: + payload["last_question_index"] = last_question_index + if response_date: + payload["response_date"] = response_date + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/checkins/{followup_uuid}/responses/", + 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 {} + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/forms/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + return response.json() + + def get_form(self, form_uuid: str) -> dict[str, Any]: + """GET /v1/forms// — form metadata and question definitions.""" + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/forms/{form_uuid}/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def submit_form_response( + self, + form_uuid: str, + content: dict[str, Any], + ) -> dict[str, Any]: + """POST /v1/forms//responses/""" + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/forms/{form_uuid}/responses/", + json={"content": content}, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def list_users(self) -> list[dict[str, Any]]: + """GET /v1/users/ — fetch all pages and return the combined results list.""" + results: list[dict[str, Any]] = [] + url: str | None = f"{self.api_url}/v1/users/" + 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: dict[str, Any] = response.json() + results.extend(body.get("results", [])) + url = body.get("next") + pages_fetched += 1 + return results + + def give_kudos( + self, + receivers: list[str], + content: str, + company_value: str | None = None, + ) -> dict[str, Any]: + """POST /v1/kudos/""" + payload: dict[str, Any] = { + "receivers": receivers, + "content": content, + } + if company_value: + payload["company_value"] = company_value + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/kudos/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + # --- Agent endpoints --- def submit_agent_report( diff --git a/dailybot_cli/commands/auth.py b/dailybot_cli/commands/auth.py index 135c5fe..d4f5d34 100644 --- a/dailybot_cli/commands/auth.py +++ b/dailybot_cli/commands/auth.py @@ -22,6 +22,21 @@ ) +def _prompt_org_selection_numbered(organizations: list[dict[str, Any]]) -> dict[str, Any]: + """Numbered org picker — fallback when questionary TUI is unavailable.""" + print_info("You belong to multiple organizations. Select one by number:") + for index, org in enumerate(organizations, start=1): + org_name: str = org.get("name", "Unknown") + org_uuid: str = org.get("uuid", "") + click.echo(f" {index}. {org_name} (uuid: {org_uuid})") + + while True: + choice: int = click.prompt("Organization number", type=int) + if 1 <= choice <= len(organizations): + return organizations[choice - 1] + print_error(f"Enter a number between 1 and {len(organizations)}.") + + def _prompt_org_selection(organizations: list[dict[str, Any]]) -> dict[str, Any]: """Display orgs and prompt the user to pick one.""" choices: list[questionary.Choice] = [ @@ -31,10 +46,12 @@ def _prompt_org_selection(organizations: list[dict[str, Any]]) -> dict[str, Any] "You belong to multiple organizations. Select one:", choices=choices, ).ask() - if selected is None: - print_error("No organization selected.") - raise SystemExit(1) - return selected + if selected is not None: + return selected + + # questionary returns None when cancelled or when the TUI cannot render + # (common after click.prompt in some terminals) — fall back to numbered input. + return _prompt_org_selection_numbered(organizations) def _print_org_list(organizations: list[dict[str, Any]]) -> None: @@ -55,7 +72,12 @@ def _resolve_org_uuid(organizations: list[dict[str, Any]], org_uuid: str) -> int def _verify_and_save( - client: DailyBotClient, email: str, code: str, organization_id: int | None + client: DailyBotClient, + email: str, + code: str, + organization_id: int | None, + *, + allow_interactive_org_pick: bool = False, ) -> None: """Verify OTP code and save credentials.""" try: @@ -74,9 +96,25 @@ def _verify_and_save( # Auto-select the only org and retry auto_org_id: int = organizations[0]["id"] print_info(f"Auto-selecting organization: {organizations[0].get('name', 'Unknown')}") - _verify_and_save(client, email, code, auto_org_id) + _verify_and_save( + client, + email, + code, + auto_org_id, + allow_interactive_org_pick=allow_interactive_org_pick, + ) return elif organizations: + if allow_interactive_org_pick: + selected_org: dict[str, Any] = _prompt_org_selection(organizations) + _verify_and_save( + client, + email, + code, + selected_org["id"], + allow_interactive_org_pick=True, + ) + return _print_org_list(organizations) print_info(f"Run: dailybot login --email={email} --code={code} --org=ORG_UUID") else: @@ -123,15 +161,8 @@ def _do_login(email: str) -> None: is_multi_org: bool = request_result.get("is_multi_org", False) organizations: list[dict[str, Any]] = request_result.get("organizations", []) - # Print org list for reference if multi-org - if is_multi_org and len(organizations) > 1: - _print_org_list(organizations) - - # Step 2: Enter code - code: str = click.prompt("Enter the 6-digit code", type=str) - code = code.strip() - - # Step 3: If multi-org, prompt for org selection before verifying + # Step 2: Pick organization before the code prompt so questionary TUI works + # reliably (click.prompt can break questionary if it runs afterward). organization_id: int | None = None if is_multi_org and len(organizations) > 1: selected_org: dict[str, Any] = _prompt_org_selection(organizations) @@ -139,8 +170,18 @@ def _do_login(email: str) -> None: elif len(organizations) == 1: organization_id = organizations[0].get("id") + # Step 3: Enter code + code: str = click.prompt("Enter the 6-digit code", type=str) + code = code.strip() + # Step 4: Verify code and save credentials - _verify_and_save(client, email, code, organization_id) + _verify_and_save( + client, + email, + code, + organization_id, + allow_interactive_org_pick=True, + ) def _request_code_non_interactive(email: str) -> None: diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py new file mode 100644 index 0000000..ae8e11a --- /dev/null +++ b/dailybot_cli/commands/checkin.py @@ -0,0 +1,78 @@ +"""Check-in commands for the user-scoped public API.""" + +import click + +from dailybot_cli.commands.public_api_helpers import require_bearer_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." + + +@click.group() +def checkin() -> None: + """Manage check-ins with your Dailybot session. + + \b + Acts as you — visibility and permissions match the webapp for your account. + """ + + +@checkin.command("list") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_list(json_mode: bool) -> None: + """List pending check-ins for today. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + \b + Examples: + dailybot checkin list + dailybot checkin list --json + """ + client = require_bearer_auth() + execute_checkin_list(client, json_mode=json_mode) + + +@checkin.command("complete") +@click.argument("followup_uuid") +@click.option( + "--answer", + "-a", + multiple=True, + help='Answer as "index=response" (0-based). Prompts when omitted.', +) +@click.option( + "--response-date", + default=None, + help="Target a specific day (YYYY-MM-DD). Defaults to today.", +) +@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_complete( + followup_uuid: str, + answer: tuple[str, ...], + response_date: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Complete a pending check-in. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + \b + Examples: + dailybot checkin complete + dailybot checkin complete -a 0="Shipped auth" -a 1="Reviewing migrations" + dailybot checkin complete --yes + """ + client = require_bearer_auth() + execute_checkin_complete( + client, + followup_uuid, + answer_flags=answer, + response_date=response_date, + assume_yes=assume_yes, + json_mode=json_mode, + ) diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py new file mode 100644 index 0000000..ed944c3 --- /dev/null +++ b/dailybot_cli/commands/form.py @@ -0,0 +1,77 @@ +"""Form commands for the user-scoped public API.""" + +import click + +from dailybot_cli.commands.public_api_helpers import require_bearer_auth +from dailybot_cli.commands.user_scoped_actions import ( + execute_form_list, + execute_form_submit, + resolve_form_content, +) + + +@click.group() +def form() -> None: + """Manage forms with your Dailybot session. + + \b + Acts as you — visibility and permissions match the webapp for your account. + """ + + +@form.command("list") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_list(json_mode: bool) -> None: + """List forms visible to you. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + \b + Examples: + dailybot form list + dailybot form list --json + """ + client = require_bearer_auth() + execute_form_list(client, json_mode=json_mode) + + +@form.command("submit") +@click.argument("form_uuid") +@click.option( + "--content", + "-c", + default=None, + help="JSON map of question UUID to answer. When omitted, prompts each question in order.", +) +@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 form_submit( + form_uuid: str, + content: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Submit a form response. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + When --content is omitted, the CLI loads the form via GET /v1/forms/{uuid}/ + and prompts each question one by one (same flow as completing a check-in). + + \b + Examples: + dailybot form submit + dailybot form submit --content '{"":"Yes"}' + dailybot form submit --content '{"":"Answer"}' --yes + """ + client = require_bearer_auth() + content_map = resolve_form_content(client, form_uuid, content) + execute_form_submit( + client, + form_uuid, + content_map, + assume_yes=assume_yes, + json_mode=json_mode, + ) diff --git a/dailybot_cli/commands/interactive.py b/dailybot_cli/commands/interactive.py index c3fdbff..2ce81e9 100644 --- a/dailybot_cli/commands/interactive.py +++ b/dailybot_cli/commands/interactive.py @@ -1,37 +1,103 @@ """Interactive mode for Dailybot CLI.""" import readline # noqa: F401 — enables arrow-key editing in input() +import sys from typing import Any import click import httpx import questionary +from questionary import Choice, Separator from dailybot_cli import __version__ from dailybot_cli.api_client import APIError, DailyBotClient from dailybot_cli.commands.auth import _do_login -from dailybot_cli.config import get_token, load_credentials +from dailybot_cli.commands.kudos import execute_kudos_give +from dailybot_cli.commands.public_api_helpers import ( + InteractiveAbort, + get_current_user_uuid, + pick_from_list, +) +from dailybot_cli.commands.user_scoped_actions import ( + collect_form_content_guided, + execute_checkin_complete, + execute_form_list, + execute_form_submit, + execute_user_list, + filter_submittable_forms, +) +from dailybot_cli.config import get_api_url, get_token, load_credentials from dailybot_cli.display import ( console, print_error, print_info, - print_pending_checkins, print_success, print_update_result, + print_warning, ) -MENU_SEND_UPDATE: str = "Send update" -MENU_VIEW_PENDING: str = "View pending check-ins" -MENU_AUTH_STATUS: str = "Auth status" -MENU_QUIT: str = "Quit" +# Stable action IDs — dispatch is keyed on these, never on display strings. +ACTION_CHECKIN_COMPLETE: str = "checkin.complete" +ACTION_CHECKIN_UPDATE: str = "checkin.update" +ACTION_FORM_LIST: str = "form.list" +ACTION_FORM_SUBMIT: str = "form.submit" +ACTION_TEAM_LIST: str = "team.list" +ACTION_TEAM_KUDOS: str = "team.kudos" +ACTION_SESSION_INFO: str = "session.info" +ACTION_EXIT: str = "exit" + +# Build the grouped menu once. Separators are section headers (non-selectable). +_I: str = " " # indent for action items — 2 spaces under each section header -MENU_CHOICES: list[str] = [ - MENU_SEND_UPDATE, - MENU_VIEW_PENDING, - MENU_AUTH_STATUS, - MENU_QUIT, +MENU_CHOICES: list[Choice | Separator] = [ + Separator("Check-ins"), + Choice(_I + "Complete pending check-ins", value=ACTION_CHECKIN_COMPLETE), + Choice(_I + "Send free-text update", value=ACTION_CHECKIN_UPDATE), + Separator("Forms"), + Choice(_I + "List forms", value=ACTION_FORM_LIST), + Choice(_I + "Submit a form", value=ACTION_FORM_SUBMIT), + Separator("Team"), + Choice(_I + "List team members", value=ACTION_TEAM_LIST), + Choice(_I + "Give kudos", value=ACTION_TEAM_KUDOS), + Separator("Session"), + Choice(_I + "View session info", value=ACTION_SESSION_INFO), + Separator(""), + Choice("Exit", value=ACTION_EXIT), ] +# Action → handler lookup — keeps run_interactive() free of long if/elif chains. +_HANDLER_MAP: dict[str, str] = { + ACTION_CHECKIN_COMPLETE: "_fill_pending_checkins", + ACTION_CHECKIN_UPDATE: "_send_update", + ACTION_FORM_LIST: "_list_forms", + ACTION_FORM_SUBMIT: "_submit_form", + ACTION_TEAM_LIST: "_list_members", + ACTION_TEAM_KUDOS: "_give_kudos", + ACTION_SESSION_INFO: "_show_auth", +} + + +def _checkin_label(checkin: dict[str, Any]) -> str: + name: str = str(checkin.get("followup_name") or "Check-in") + question_count: int = len(checkin.get("template_questions", [])) + return f"{name} ({question_count} question{'s' if question_count != 1 else ''})" + + +def _form_label(form: dict[str, Any]) -> str: + name: str = str(form.get("name") or form.get("id") or "Form") + active: bool = bool(form.get("is_active")) + suffix: str = "" if active else " [inactive]" + return f"{name}{suffix}" + + +def _user_label(user: dict[str, Any]) -> str: + return str(user.get("full_name") or user.get("uuid") or "Unknown") + + +def _return_to_menu() -> None: + """Abort the current action and return to the main menu (Esc).""" + print_info("Cancelled.") + def run_interactive() -> None: """Run the interactive TUI mode.""" @@ -39,42 +105,227 @@ def run_interactive() -> None: token: str | None = get_token() console.print(f"\n[bold]Dailybot CLI[/bold] [dim]v{__version__}[/dim]") + api_url: str = get_api_url() + console.print(f"[dim]API: {api_url}[/dim]") if not token or not creds: console.print() print_info("Let's get you logged in.") console.print() email: str = click.prompt("Email") - _do_login(email) + try: + _do_login(email) + except SystemExit: + print_error("Login failed.") + return creds = load_credentials() else: - email = creds.get("email", "") if creds else "" - org_stored: Any = creds.get("organization", "") if creds else "" - org: str = org_stored.get("name", "") if isinstance(org_stored, dict) else str(org_stored) - org_uuid: str = creds.get("organization_uuid", "") if creds else "" - console.print(f"Logged in as {email} ({org})") - if org_uuid: - console.print(f"[dim]Org UUID: {org_uuid}[/dim]") + stored_api: str = str(creds.get("api_url") or api_url).rstrip("/") + if stored_api != api_url.rstrip("/"): + console.print() + print_warning( + f"Stored login targets {stored_api}, but this session uses {api_url}. " + "Run: dailybot login" + ) + console.print() + email = click.prompt("Email") + try: + _do_login(email) + except SystemExit: + print_error("Login failed.") + return + creds = load_credentials() + else: + email = creds.get("email", "") if creds else "" + org_stored: Any = creds.get("organization", "") if creds else "" + org: str = ( + org_stored.get("name", "") if isinstance(org_stored, dict) else str(org_stored) + ) + org_uuid: str = creds.get("organization_uuid", "") if creds else "" + console.print(f"Logged in as {email} ({org})") + if org_uuid: + console.print(f"[dim]Org UUID: {org_uuid}[/dim]") console.print() client: DailyBotClient = DailyBotClient() while True: console.print() - choice: str | None = questionary.select( + action: str | None = questionary.select( "What would you like to do?", choices=MENU_CHOICES, ).ask() - if choice is None or choice == MENU_QUIT: + # None means the user pressed Esc/Ctrl-C at the top-level menu — stay in + # the loop so they can keep navigating (Quit is the explicit exit path). + if action is None: + continue + + if action == ACTION_EXIT: print_info("Goodbye!") break - elif choice == MENU_SEND_UPDATE: - _send_update(client) - elif choice == MENU_VIEW_PENDING: - _view_pending(client) - elif choice == MENU_AUTH_STATUS: - _show_auth(client) + + handler_name: str | None = _HANDLER_MAP.get(action) + if handler_name is None: + continue + + handler = getattr(sys.modules[__name__], handler_name) + try: + handler(client) + except InteractiveAbort: + _return_to_menu() + + +def _fill_pending_checkins(client: DailyBotClient) -> None: + """Pick a pending check-in and guide the user through completing it.""" + try: + with console.status("Fetching pending check-ins..."): + status_data: dict[str, Any] = client.get_status() + except APIError as e: + print_error(e.detail) + return + + checkins: list[dict[str, Any]] = status_data.get("pending_checkins", []) + if not checkins: + print_info("No pending check-ins.") + return + + selected: dict[str, Any] | None = pick_from_list( + checkins, + "Which check-in do you want to complete?", + _checkin_label, + numbered_fallback=False, + ) + if selected is None: + raise InteractiveAbort() + + followup_uuid: str = str(selected.get("followup_uuid", "")) + if not followup_uuid: + print_error("Selected check-in has no followup UUID.") + return + + try: + execute_checkin_complete( + client, + followup_uuid, + status_data=status_data, + interactive=True, + assume_yes=True, + ) + except SystemExit: + return + + +def _list_forms(client: DailyBotClient) -> None: + """List forms visible to the user.""" + try: + execute_form_list(client) + except SystemExit: + return + + +def _submit_form(client: DailyBotClient) -> None: + """Pick a form and submit answers interactively.""" + try: + with console.status("Fetching forms..."): + forms: list[dict[str, Any]] = filter_submittable_forms( + client.list_forms(include_questions=False) + ) + except APIError as e: + print_error(e.detail) + return + + if not forms: + print_info("No forms visible to you.") + return + + selected: dict[str, Any] | None = pick_from_list( + forms, + "Which form do you want to submit?", + _form_label, + numbered_fallback=False, + ) + if selected is None: + raise InteractiveAbort() + + form_uuid: str = str(selected.get("id") or "") + form_name: str = str(selected.get("name") or form_uuid) + if not form_uuid: + print_error("Selected form has no UUID.") + return + + try: + content_map: dict[str, Any] = collect_form_content_guided( + client, + form_uuid, + interactive=True, + ) + execute_form_submit( + client, + form_uuid, + content_map, + form_name=form_name, + ) + except SystemExit: + return + + +def _list_members(client: DailyBotClient) -> None: + """List organization members.""" + try: + execute_user_list(client) + except SystemExit: + return + + +def _give_kudos(client: DailyBotClient) -> None: + """Pick a teammate and send kudos interactively.""" + try: + with console.status("Loading team members..."): + users: list[dict[str, Any]] = client.list_users() + current_uuid: str | None = get_current_user_uuid(client) + except APIError as e: + print_error(e.detail) + return + + teammates: list[dict[str, Any]] = [ + user for user in users if str(user.get("uuid", "")) != str(current_uuid or "") + ] + if not teammates: + print_error("No teammates available to receive kudos.") + return + + selected_user: dict[str, Any] | None = pick_from_list( + teammates, + "Who should receive kudos?", + _user_label, + numbered_fallback=False, + ) + if selected_user is None: + raise InteractiveAbort() + + message: str | None = questionary.text("Kudos message (team-visible):").ask() + if message is None: + raise InteractiveAbort() + message = message.strip() + if not message: + print_error("Empty message. Nothing sent.") + return + + receiver_uuid: str = str(selected_user["uuid"]) + receiver_name: str = str(selected_user.get("full_name") or receiver_uuid) + + try: + execute_kudos_give( + client, + receiver_uuid, + receiver_name, + message, + current_uuid, + assume_yes=True, + ) + except SystemExit: + return def _send_update(client: DailyBotClient) -> None: @@ -108,17 +359,6 @@ def _send_update(client: DailyBotClient) -> None: print_error(e.detail) -def _view_pending(client: DailyBotClient) -> None: - """Fetch and display pending check-ins.""" - try: - with console.status("Fetching..."): - data: dict[str, Any] = client.get_status() - checkins: list[dict[str, Any]] = data.get("pending_checkins", []) - print_pending_checkins(checkins) - except APIError as e: - print_error(e.detail) - - def _show_auth(client: DailyBotClient) -> None: """Show current auth status.""" try: diff --git a/dailybot_cli/commands/kudos.py b/dailybot_cli/commands/kudos.py new file mode 100644 index 0000000..02f259d --- /dev/null +++ b/dailybot_cli/commands/kudos.py @@ -0,0 +1,146 @@ +"""Kudos commands for the user-scoped public API.""" + +from typing import Any + +import click + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + EXIT_PERMISSION_DENIED, + EXIT_USAGE_ERROR, + confirm_write, + emit_json, + exit_for_api_error, + get_current_user_uuid, + require_bearer_auth, + resolve_user_by_name_or_uuid, +) +from dailybot_cli.display import ( + console, + print_error, + print_kudos_result, +) + + +def execute_kudos_give( + client: DailyBotClient, + receiver_uuid: str, + receiver_name: str, + message: str, + current_uuid: str | None, + *, + value: str | None = None, + assume_yes: bool = False, + json_mode: bool = False, +) -> None: + """Send kudos after the receiver has already been resolved.""" + if current_uuid and receiver_uuid == current_uuid: + error_message: str = "You cannot give kudos to yourself." + if json_mode: + emit_json({"error": error_message, "status": 403}) + else: + print_error(error_message) + raise SystemExit(EXIT_PERMISSION_DENIED) + + summary_lines: list[str] = [ + f"To: {receiver_name}", + f"Receiver UUID: {receiver_uuid}", + f"Message: {message}", + ] + if value: + summary_lines.append(f"Company value: {value}") + + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Sending kudos..."): + result: dict[str, Any] = client.give_kudos( + receivers=[receiver_uuid], + content=message, + company_value=value, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + + print_kudos_result(receiver_name, result) + + +@click.group() +def kudos() -> None: + """Give kudos with your Dailybot session. + + \b + Acts as you — visibility and permissions match the webapp for your account. + """ + + +@kudos.command("give") +@click.option( + "--to", + "-t", + "receiver", + required=True, + help="Receiver full name or UUID (resolved via GET /v1/users/).", +) +@click.option( + "--message", + "-m", + required=True, + help="Kudos message (team-visible).", +) +@click.option( + "--value", + default=None, + help="Optional company value UUID.", +) +@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 kudos_give( + receiver: str, + message: str, + value: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Give kudos to a teammate. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + Receivers are resolved by name against your organization directory — never guessed. + + \b + Examples: + dailybot kudos give --to "Jane Doe" --message "Great release work!" + dailybot kudos give --to --message "Thanks!" --yes + """ + client = require_bearer_auth() + + try: + with console.status("Resolving receiver..."): + users: list[dict[str, Any]] = client.list_users() + receiver_uuid, receiver_name = resolve_user_by_name_or_uuid(users, receiver) + current_uuid: str | None = get_current_user_uuid(client) + except APIError as exc: + exit_for_api_error(exc, json_mode) + 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 + + execute_kudos_give( + client, + receiver_uuid, + receiver_name, + message, + current_uuid, + value=value, + assume_yes=assume_yes, + json_mode=json_mode, + ) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py new file mode 100644 index 0000000..839a52b --- /dev/null +++ b/dailybot_cli/commands/public_api_helpers.py @@ -0,0 +1,212 @@ +"""Shared helpers for user-scoped public API commands.""" + +import json +import re +from collections.abc import Callable +from typing import Any, NoReturn + +import click +import questionary + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.config import get_token +from dailybot_cli.display import error_console, print_error, print_info + +USER_SCOPED_MODEL_HELP: str = ( + "Acts as you. You can only see and act on what you could in the webapp." +) + +EXIT_USAGE_ERROR: int = 2 +EXIT_NOT_AUTHENTICATED: int = 3 +EXIT_PERMISSION_DENIED: int = 4 +EXIT_QUOTA_EXHAUSTED: int = 5 +EXIT_RATE_LIMITED: int = 6 +EXIT_USER_ABORTED: int = 7 + + +class InteractiveAbort(Exception): + """Raised when the user cancels an interactive prompt (e.g. Esc).""" + + +UUID_PATTERN: re.Pattern[str] = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +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") + raise SystemExit(EXIT_NOT_AUTHENTICATED) + return DailyBotClient() + + +def emit_json(data: Any) -> None: + """Print machine-readable JSON to stdout.""" + click.echo(json.dumps(data)) + + +def emit_json_error(message: str, status: int) -> None: + """Print a machine-readable error object to stdout.""" + emit_json({"error": message, "status": status}) + + +def exit_for_api_error(exc: APIError, json_mode: bool) -> NoReturn: + """Map API failures to user-facing messages and process exit codes.""" + if exc.status_code == 401: + message: str = "Session expired. Run: dailybot login" + exit_code: int = EXIT_NOT_AUTHENTICATED + elif exc.status_code == 403: + message = exc.detail + exit_code = EXIT_PERMISSION_DENIED + elif exc.status_code == 402: + message = "Form response quota exhausted for your organization." + exit_code = EXIT_QUOTA_EXHAUSTED + elif exc.status_code == 406: + 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." + exit_code = EXIT_RATE_LIMITED + else: + message = exc.detail + exit_code = 1 + + if json_mode: + emit_json_error(message, exc.status_code) + else: + print_error(message) + raise SystemExit(exit_code) + + +def confirm_write(summary_lines: list[str], assume_yes: bool) -> None: + """Prompt for confirmation before a team-visible write.""" + if assume_yes: + return + + for line in summary_lines: + error_console.print(line) + + if not click.confirm( + "Proceed? This will be visible to your team.", + default=False, + ): + error_console.print("[dim]Aborted.[/dim]") + raise SystemExit(EXIT_USER_ABORTED) + + +def normalize_checkin_list_json(data: dict[str, Any]) -> dict[str, Any]: + """Add derived 0-based question indexes for JSON consumers.""" + pending: list[dict[str, Any]] = [] + for checkin in data.get("pending_checkins", []): + questions: list[dict[str, Any]] = [] + for index, question in enumerate(checkin.get("template_questions", [])): + enriched: dict[str, Any] = dict(question) + enriched["index"] = index + questions.append(enriched) + entry: dict[str, Any] = dict(checkin) + entry["template_questions"] = questions + pending.append(entry) + return {"pending_checkins": pending, "count": data.get("count", len(pending))} + + +def find_pending_checkin( + pending_checkins: list[dict[str, Any]], + followup_uuid: str, +) -> dict[str, Any] | None: + """Return the pending check-in matching followup_uuid, if present.""" + for checkin in pending_checkins: + if checkin.get("followup_uuid") == followup_uuid: + return checkin + return None + + +def parse_answer_flags(answers: tuple[str, ...]) -> dict[int, str]: + """Parse repeatable ``index=response`` answer flags.""" + parsed: dict[int, str] = {} + for raw in answers: + if "=" not in raw: + raise ValueError(f'Invalid answer format "{raw}". Use index=response (e.g. 0=Done).') + index_str, response = raw.split("=", 1) + try: + index: int = int(index_str.strip()) + except ValueError as exc: + raise ValueError(f'Invalid answer index "{index_str}".') from exc + parsed[index] = response + return parsed + + +def resolve_user_by_name_or_uuid( + users: list[dict[str, Any]], + identifier: str, +) -> tuple[str, str]: + """Resolve a user UUID and display name from a UUID or name fragment.""" + if UUID_PATTERN.match(identifier): + for user in users: + if user.get("uuid") == identifier: + name: str = str(user.get("full_name") or identifier) + return identifier, name + return identifier, identifier + + exact_matches: list[dict[str, Any]] = [ + user for user in users if str(user.get("full_name", "")).lower() == identifier.lower() + ] + if len(exact_matches) == 1: + match: dict[str, Any] = exact_matches[0] + return str(match["uuid"]), str(match.get("full_name") or match["uuid"]) + + partial_matches: list[dict[str, Any]] = [ + user for user in users if identifier.lower() in str(user.get("full_name", "")).lower() + ] + if len(partial_matches) == 1: + match = partial_matches[0] + return str(match["uuid"]), str(match.get("full_name") or match["uuid"]) + if len(partial_matches) > 1: + names: str = ", ".join(str(user.get("full_name", "")) for user in partial_matches) + raise ValueError(f'Ambiguous receiver "{identifier}". Matches: {names}') + + raise ValueError(f'No user found matching "{identifier}".') + + +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() + user_raw: Any = data.get("user") + if isinstance(user_raw, dict): + uuid_value: Any = user_raw.get("uuid") + if uuid_value: + return str(uuid_value) + return None + + +def pick_from_list( + items: list[Any], + prompt: str, + label_fn: Callable[[Any], str], + *, + numbered_fallback: bool = True, +) -> Any | None: + """Pick an item with questionary, falling back to a numbered list.""" + if not items: + return None + + choices: list[questionary.Choice] = [ + questionary.Choice(title=label_fn(item), value=item) for item in items + ] + selected: Any | None = questionary.select(prompt, choices=choices).ask() + if selected is not None: + return selected + if not numbered_fallback: + return None + + print_info("Select by number:") + for index, item in enumerate(items, start=1): + click.echo(f" {index}. {label_fn(item)}") + + while True: + choice: int = click.prompt("Number", type=int) + if 1 <= choice <= len(items): + return items[choice - 1] + print_error(f"Enter a number between 1 and {len(items)}.") diff --git a/dailybot_cli/commands/user.py b/dailybot_cli/commands/user.py new file mode 100644 index 0000000..597960f --- /dev/null +++ b/dailybot_cli/commands/user.py @@ -0,0 +1,32 @@ +"""User directory commands for the user-scoped public API.""" + +import click + +from dailybot_cli.commands.public_api_helpers import require_bearer_auth +from dailybot_cli.commands.user_scoped_actions import execute_user_list + + +@click.group() +def user() -> None: + """Browse your organization with your Dailybot session. + + \b + Acts as you — visibility and permissions match the webapp for your account. + """ + + +@user.command("list") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def user_list(json_mode: bool) -> None: + """List team members in your organization. + + \b + Acts as you. You can only see and act on what you could in the webapp. + + \b + Examples: + dailybot user list + dailybot user list --json + """ + client = require_bearer_auth() + execute_user_list(client, json_mode=json_mode) diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py new file mode 100644 index 0000000..737419b --- /dev/null +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -0,0 +1,532 @@ +"""Shared handlers for user-scoped public API commands and interactive mode.""" + +import json +from typing import Any + +import click +import questionary + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + EXIT_USAGE_ERROR, + InteractiveAbort, + confirm_write, + emit_json, + exit_for_api_error, + find_pending_checkin, + normalize_checkin_list_json, + parse_answer_flags, +) +from dailybot_cli.display import ( + console, + print_checkin_complete_result, + print_checkin_list_overview, + print_error, + print_form_submit_result, + print_forms_table, + print_info, + print_pending_checkins, + print_users_table, +) + + +def collect_checkin_answers( + questions: list[dict[str, Any]], + answer_flags: tuple[str, ...], + *, + interactive: bool = False, +) -> list[Any]: + """Collect answers from flags or interactive prompts.""" + collected: list[Any] = [] + if answer_flags: + try: + indexed: dict[int, str] = parse_answer_flags(answer_flags) + except ValueError as exc: + print_error(str(exc)) + raise SystemExit(EXIT_USAGE_ERROR) from exc + + for index, question in enumerate(questions): + if index not in indexed: + print_error( + f'Missing answer for question {index}: "{question.get("question", "")}"' + ) + raise SystemExit(EXIT_USAGE_ERROR) + collected.append(indexed[index]) + return collected + + for index, question in enumerate(questions): + prompt_text: str = str(question.get("question", f"Question {index + 1}")) + if interactive: + collected.append(_prompt_form_answer(question, prompt_text, interactive=True)) + else: + answer: str = click.prompt(prompt_text, type=str) + collected.append(answer) + return collected + + +def build_checkin_responses( + questions: list[dict[str, Any]], + answers: list[Any], +) -> list[dict[str, Any]]: + """Build the POST payload entries for complete_checkin.""" + responses: list[dict[str, Any]] = [] + for index, (question, answer) in enumerate(zip(questions, answers, strict=True)): + responses.append( + { + "uuid": question["uuid"], + "index": index, + "response": answer, + } + ) + return responses + + +FORM_QUESTION_TYPES_TEXT: frozenset[str] = frozenset( + {"text", "text_field", "short_text", "long_text", "textarea", "string"} +) +FORM_QUESTION_TYPES_NUMERIC: frozenset[str] = frozenset( + {"number", "numeric", "integer", "int", "float", "decimal"} +) +FORM_QUESTION_TYPES_BOOLEAN: frozenset[str] = frozenset( + {"boolean", "bool", "yes_no", "yes/no", "toggle"} +) +FORM_QUESTION_TYPES_CHOICE: frozenset[str] = frozenset( + { + "choice", + "choices", + "multiple_choice", + "single_choice", + "select", + "dropdown", + "radio", + } +) + + +def filter_submittable_forms(forms: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return forms that have an ID and can be submitted.""" + return [form for form in forms if form.get("id")] + + +def extract_form_questions(form_data: dict[str, Any]) -> list[dict[str, Any]]: + """Return question definitions from a form detail payload.""" + for key in ("questions", "template_questions", "fields"): + raw: Any = form_data.get(key) + if isinstance(raw, list) and raw: + return raw + return [] + + +def _form_question_uuid(question: dict[str, Any]) -> str | None: + for key in ("uuid", "id", "question_uuid"): + value: Any = question.get(key) + if value: + return str(value) + return None + + +def _form_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}" + + +def _form_question_choices(question: dict[str, Any]) -> list[str]: + raw: Any = question.get("choices") or question.get("options") or [] + if not isinstance(raw, list): + return [] + + choices: list[str] = [] + for item in raw: + if isinstance(item, str): + choices.append(item) + elif isinstance(item, dict): + label: Any = item.get("label") or item.get("text") or item.get("value") + if label: + choices.append(str(label)) + return choices + + +def _classify_form_question_type(question: dict[str, Any]) -> str: + """Map API question_type values to a supported prompt strategy.""" + raw_type: Any = question.get("question_type") or question.get("type") or "" + normalized: str = str(raw_type).strip().lower().replace("-", "_").replace(" ", "_") + + if normalized in FORM_QUESTION_TYPES_CHOICE or _form_question_choices(question): + return "choice" + if normalized in FORM_QUESTION_TYPES_BOOLEAN: + return "boolean" + if normalized in FORM_QUESTION_TYPES_NUMERIC: + return "numeric" + return "text" + + +def _prompt_choice_answer( + prompt_text: str, + choices: list[str], + *, + interactive: bool = False, +) -> str: + """Prompt for a single-choice answer.""" + if not choices: + print_error("Choice question is missing options.") + raise SystemExit(EXIT_USAGE_ERROR) + + selected: str | None = questionary.select(prompt_text, choices=choices).ask() + if selected is not None: + return selected + if interactive: + raise InteractiveAbort() + + print_info("Select by number:") + for index, choice in enumerate(choices, start=1): + click.echo(f" {index}. {choice}") + + while True: + choice_index: int = click.prompt("Number", type=int) + if 1 <= choice_index <= len(choices): + return choices[choice_index - 1] + print_error(f"Enter a number between 1 and {len(choices)}.") + + +def _prompt_boolean_answer(prompt_text: str, *, interactive: bool = False) -> bool: + """Prompt for a yes/no answer.""" + selected: bool | None = questionary.confirm(prompt_text, default=False).ask() + if selected is not None: + return selected + if interactive: + raise InteractiveAbort() + + while True: + raw: str = click.prompt(f"{prompt_text} (yes/no)", type=str).strip().lower() + if raw in {"y", "yes", "true", "1"}: + return True + if raw in {"n", "no", "false", "0"}: + return False + print_error('Enter "yes" or "no".') + + +def _prompt_numeric_answer(prompt_text: str, *, interactive: bool = False) -> int | float: + """Prompt for a numeric answer.""" + while True: + if interactive: + raw_interactive: str | None = questionary.text(prompt_text).ask() + if raw_interactive is None: + raise InteractiveAbort() + raw = raw_interactive.strip() + else: + raw = click.prompt(prompt_text, type=str).strip() + try: + if "." in raw: + return float(raw) + return int(raw) + except ValueError: + print_error("Enter a valid number.") + + +def _prompt_text_answer(prompt_text: str, *, interactive: bool = False) -> str: + """Prompt for a free-text answer.""" + if interactive: + raw: str | None = questionary.text(prompt_text).ask() + if raw is None: + raise InteractiveAbort() + return raw + return click.prompt(prompt_text, type=str) + + +def _prompt_form_answer( + question: dict[str, Any], + prompt_text: str, + *, + interactive: bool = False, +) -> Any: + """Prompt once for a form question according to its type.""" + question_type: str = _classify_form_question_type(question) + + if question_type == "choice": + return _prompt_choice_answer( + prompt_text, + _form_question_choices(question), + interactive=interactive, + ) + if question_type == "boolean": + return _prompt_boolean_answer(prompt_text, interactive=interactive) + if question_type == "numeric": + return _prompt_numeric_answer(prompt_text, interactive=interactive) + return _prompt_text_answer(prompt_text, interactive=interactive) + + +def collect_form_answers_by_label( + questions: list[dict[str, Any]], + *, + interactive: bool = False, +) -> dict[str, Any]: + """Prompt for each form question in order and build the content map.""" + content: dict[str, Any] = {} + for index, question in enumerate(questions): + question_uuid: str | None = _form_question_uuid(question) + if not question_uuid: + continue + + prompt_text: str = _form_question_label(question, index) + content[question_uuid] = _prompt_form_answer( + question, + prompt_text, + interactive=interactive, + ) + + if not content: + print_error("This form has no answerable questions.") + raise SystemExit(EXIT_USAGE_ERROR) + return content + + +def collect_form_content_guided( + client: DailyBotClient, + form_uuid: str, + *, + interactive: bool = False, +) -> dict[str, Any]: + """Load form questions from the API and collect answers interactively.""" + try: + with console.status("Loading form questions..."): + form_data: dict[str, Any] = client.get_form(form_uuid) + except APIError as exc: + if exc.status_code == 404: + print_error( + "Form question definitions are not available. " + "The API must expose GET /v1/forms/{uuid}/ with a questions list." + ) + raise SystemExit(EXIT_USAGE_ERROR) from exc + exit_for_api_error(exc, json_mode=False) + + questions: list[dict[str, Any]] = extract_form_questions(form_data) + if not questions: + print_error( + "This form returned no question definitions. " + "GET /v1/forms/{uuid}/ must include a questions array." + ) + raise SystemExit(EXIT_USAGE_ERROR) + + return collect_form_answers_by_label(questions, interactive=interactive) + + +def parse_form_content_json(raw_content: str) -> dict[str, Any]: + """Parse a --content JSON map.""" + try: + parsed: Any = json.loads(raw_content) + except json.JSONDecodeError as exc: + print_error(f"Invalid JSON for --content: {exc}") + raise SystemExit(EXIT_USAGE_ERROR) from exc + + if not isinstance(parsed, dict): + print_error("--content must be a JSON object mapping question UUIDs to answers.") + raise SystemExit(EXIT_USAGE_ERROR) + + return parsed + + +def resolve_form_content( + client: DailyBotClient, + form_uuid: str, + raw_content: str | None, +) -> dict[str, Any]: + """Parse --content JSON or prompt for each question when omitted.""" + if raw_content: + return parse_form_content_json(raw_content) + return collect_form_content_guided(client, form_uuid) + + +def find_form_name(forms: list[dict[str, Any]], form_uuid: str) -> str: + """Return the display name for a form UUID when known.""" + for form in forms: + if form.get("id") == form_uuid: + return str(form.get("name") or form_uuid) + return form_uuid + + +def execute_checkin_list( + client: DailyBotClient, + *, + json_mode: bool = False, +) -> dict[str, Any] | None: + """Fetch and display pending check-ins.""" + try: + with console.status("Fetching pending check-ins..."): + data: dict[str, Any] = client.get_status() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(normalize_checkin_list_json(data)) + return data + + checkins: list[dict[str, Any]] = data.get("pending_checkins", []) + print_checkin_list_overview(data.get("count", len(checkins)), checkins) + if checkins: + print_pending_checkins(checkins) + return data + + +def execute_checkin_complete( + client: DailyBotClient, + followup_uuid: str, + *, + answer_flags: tuple[str, ...] = (), + response_date: str | None = None, + assume_yes: bool = False, + json_mode: bool = False, + status_data: dict[str, Any] | None = None, + interactive: bool = False, +) -> None: + """Complete a pending check-in.""" + if status_data is None: + try: + with console.status("Loading check-in questions..."): + status_data = client.get_status() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + checkin_data: dict[str, Any] | None = find_pending_checkin( + status_data.get("pending_checkins", []), + followup_uuid, + ) + if checkin_data is None: + message: str = ( + f'No pending check-in found for followup "{followup_uuid}". Run: dailybot checkin list' + ) + if json_mode: + emit_json({"error": message, "status": 0}) + else: + print_error(message) + raise SystemExit(EXIT_USAGE_ERROR) + + questions: list[dict[str, Any]] = checkin_data.get("template_questions", []) + if not questions: + print_error("This check-in has no questions to answer.") + raise SystemExit(EXIT_USAGE_ERROR) + + answers: list[Any] = collect_checkin_answers( + questions, + answer_flags, + interactive=interactive, + ) + responses: list[dict[str, Any]] = build_checkin_responses(questions, answers) + last_question_index: int = len(responses) - 1 + followup_name: str = str(checkin_data.get("followup_name", followup_uuid)) + + summary_lines: list[str] = [ + f"Check-in: {followup_name}", + f"Followup UUID: {followup_uuid}", + ] + for index, question in enumerate(questions): + summary_lines.append(f" Q{index + 1}: {question.get('question', '')}") + summary_lines.append(f" A{index + 1}: {answers[index]}") + if response_date: + summary_lines.append(f"Response date: {response_date}") + + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Submitting check-in..."): + result: dict[str, Any] = client.complete_checkin( + followup_uuid=followup_uuid, + responses=responses, + last_question_index=last_question_index, + response_date=response_date, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + + print_checkin_complete_result(followup_name, result) + + +def execute_form_list( + client: DailyBotClient, + *, + json_mode: bool = False, +) -> list[dict[str, Any]] | None: + """Fetch and display forms visible to the user (with question counts).""" + try: + with console.status("Fetching forms..."): + forms: list[dict[str, Any]] = client.list_forms(include_questions=True) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(forms) + return forms + + print_forms_table(filter_submittable_forms(forms)) + return forms + + +def execute_form_submit( + client: DailyBotClient, + form_uuid: str, + content_map: dict[str, Any], + *, + form_name: str | None = None, + assume_yes: bool = False, + json_mode: bool = False, +) -> None: + """Submit a form response.""" + resolved_name: str = form_name or form_uuid + if form_name is None: + try: + with console.status("Looking up form..."): + forms: list[dict[str, Any]] = client.list_forms() + resolved_name = find_form_name(forms, form_uuid) + except APIError: + resolved_name = form_uuid + + summary_lines: list[str] = [ + f"Form: {resolved_name}", + f"Form UUID: {form_uuid}", + "Answers:", + ] + for question_uuid, answer in content_map.items(): + summary_lines.append(f" {question_uuid}: {answer}") + + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Submitting form response..."): + result: dict[str, Any] = client.submit_form_response( + form_uuid=form_uuid, + content=content_map, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + + print_form_submit_result(resolved_name, result) + + +def execute_user_list( + client: DailyBotClient, + *, + json_mode: bool = False, +) -> list[dict[str, Any]] | None: + """Fetch and display organization members.""" + try: + with console.status("Fetching team members..."): + users: list[dict[str, Any]] = client.list_users() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(users) + return users + + print_users_table(users) + return users diff --git a/dailybot_cli/config.py b/dailybot_cli/config.py index 4d5a3b3..bc9e200 100644 --- a/dailybot_cli/config.py +++ b/dailybot_cli/config.py @@ -23,17 +23,40 @@ def set_api_url_override(url: str) -> None: def get_config_dir() -> Path: - """Return the config directory, creating it if needed.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - return CONFIG_DIR + """Return the config directory, creating it if needed. + + Honors ``DAILYBOT_CONFIG_DIR`` when set (e.g. clitest sandboxes). Otherwise + uses the module-level ``CONFIG_DIR`` (``~/.config/dailybot/``). + """ + env_override: str | None = os.environ.get("DAILYBOT_CONFIG_DIR") + path: Path = Path(env_override) if env_override else CONFIG_DIR + path.mkdir(parents=True, exist_ok=True) + return path + + +def _credentials_path() -> Path: + return get_config_dir() / "credentials.json" + + +def _config_path() -> Path: + return get_config_dir() / "config.json" + + +def _org_cache_path() -> Path: + return get_config_dir() / "org_cache.json" + + +def _agents_path() -> Path: + return get_config_dir() / "agents.json" def load_credentials() -> dict[str, Any] | None: """Load stored credentials from disk.""" - if not CREDENTIALS_FILE.exists(): + creds_path: Path = _credentials_path() + if not creds_path.exists(): return None try: - data: dict[str, Any] = json.loads(CREDENTIALS_FILE.read_text()) + data: dict[str, Any] = json.loads(creds_path.read_text()) return data if data.get("token") else None except (json.JSONDecodeError, KeyError): return None @@ -47,8 +70,9 @@ def save_credentials( api_url: str = DEFAULT_API_URL, ) -> None: """Save credentials to disk.""" + creds_path: Path = _credentials_path() get_config_dir() - CREDENTIALS_FILE.write_text( + creds_path.write_text( json.dumps( { "token": token, @@ -61,13 +85,14 @@ def save_credentials( ) ) # Restrict file permissions (owner read/write only) - os.chmod(CREDENTIALS_FILE, 0o600) + os.chmod(creds_path, 0o600) def clear_credentials() -> None: """Remove stored credentials.""" - if CREDENTIALS_FILE.exists(): - CREDENTIALS_FILE.unlink() + creds_path: Path = _credentials_path() + if creds_path.exists(): + creds_path.unlink() def get_api_url() -> str: @@ -96,10 +121,11 @@ def get_token() -> str | None: def load_config() -> dict[str, Any]: """Read config.json, return {} if missing.""" - if not CONFIG_FILE.exists(): + config_path: Path = _config_path() + if not config_path.exists(): return {} try: - return json.loads(CONFIG_FILE.read_text()) + return json.loads(config_path.read_text()) except (json.JSONDecodeError, KeyError): return {} @@ -113,8 +139,9 @@ def save_config(data: dict[str, Any]) -> None: else: existing[key] = value get_config_dir() - CONFIG_FILE.write_text(json.dumps(existing, indent=2)) - os.chmod(CONFIG_FILE, 0o600) + config_path: Path = _config_path() + config_path.write_text(json.dumps(existing, indent=2)) + os.chmod(config_path, 0o600) def get_api_key() -> str | None: @@ -130,15 +157,16 @@ def save_org_cache(email: str, organizations: list[dict[str, Any]]) -> None: """Cache the org list from request_code for UUID resolution in step 2.""" get_config_dir() data: dict[str, Any] = {"email": email, "organizations": organizations} - ORG_CACHE_FILE.write_text(json.dumps(data)) + _org_cache_path().write_text(json.dumps(data)) def load_org_cache(email: str) -> list[dict[str, Any]] | None: """Load cached org list for the given email. Returns None if missing or stale.""" - if not ORG_CACHE_FILE.exists(): + cache_path: Path = _org_cache_path() + if not cache_path.exists(): return None try: - data: dict[str, Any] = json.loads(ORG_CACHE_FILE.read_text()) + data: dict[str, Any] = json.loads(cache_path.read_text()) except (json.JSONDecodeError, OSError): return None if data.get("email") != email: @@ -148,8 +176,9 @@ def load_org_cache(email: str) -> list[dict[str, Any]] | None: def clear_org_cache() -> None: """Remove the org cache file.""" - if ORG_CACHE_FILE.exists(): - ORG_CACHE_FILE.unlink() + cache_path: Path = _org_cache_path() + if cache_path.exists(): + cache_path.unlink() def get_agent_auth() -> str | None: @@ -179,10 +208,11 @@ def _slugify(name: str) -> str: def load_agents() -> dict[str, Any]: """Read agents.json, return {} if missing.""" - if not AGENTS_FILE.exists(): + agents_path: Path = _agents_path() + if not agents_path.exists(): return {} try: - return json.loads(AGENTS_FILE.read_text()) + return json.loads(agents_path.read_text()) except (json.JSONDecodeError, OSError): return {} @@ -190,8 +220,9 @@ def load_agents() -> dict[str, Any]: def _save_agents(data: dict[str, Any]) -> None: """Write agents.json with restricted permissions.""" get_config_dir() - AGENTS_FILE.write_text(json.dumps(data, indent=2)) - os.chmod(AGENTS_FILE, 0o600) + agents_path: Path = _agents_path() + agents_path.write_text(json.dumps(data, indent=2)) + os.chmod(agents_path, 0o600) def save_agent_profile( @@ -407,7 +438,7 @@ def resolve_active_profile( print_warning( f"Profile '{profile_slug}' from {repo_path} not found in " - f"{AGENTS_FILE}. Using session credentials instead." + f"{_agents_path()}. Using session credentials instead." ) else: profile_data = get_default_profile() diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 0d18c72..c5b5fd8 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -346,3 +346,86 @@ def print_update_result(data: dict[str, Any]) -> None: action: str = followup.get("action", "created") label: str = "Updated" if action == "updated" else "Submitted" console.print(f" [dim]-[/dim] {name} [dim]({label})[/dim]") + + +def print_checkin_list_overview(count: int, checkins: list[dict[str, Any]]) -> None: + """Display a summary table of pending check-ins with UUIDs.""" + if not checkins: + print_info("No pending check-ins for today.") + return + + table: Table = Table(title=f"Pending Check-ins ({count})", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("Followup UUID", style="dim") + table.add_column("Questions", justify="right") + for checkin in checkins: + table.add_row( + str(checkin.get("followup_name", "Check-in")), + str(checkin.get("followup_uuid", "")), + str(len(checkin.get("template_questions", []))), + ) + console.print(table) + + +def print_checkin_complete_result(followup_name: str, data: dict[str, Any]) -> None: + """Display the result of completing a check-in.""" + response_id: str = str(data.get("uuid") or data.get("id") or "N/A") + print_success(f'Check-in completed for "{followup_name}"') + print_info(f"Response ID: {response_id}") + + +def print_forms_table(forms: list[dict[str, Any]]) -> None: + """Display visible forms in a table.""" + if not forms: + print_info("No forms visible to you.") + return + + table: Table = Table(title="Forms", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("Form UUID", style="dim") + table.add_column("Questions", style="dim", justify="right") + for form in forms: + form_id: str = str(form.get("id") or "") + if not form_id: + continue + question_count: int = len( + form.get("questions") or form.get("template_questions") or form.get("fields") or [] + ) + count_str: str = str(question_count) if question_count else "—" + table.add_row( + str(form.get("name", "")), + form_id, + count_str, + ) + console.print(table) + + +def print_form_submit_result(form_name: str, data: dict[str, Any]) -> None: + """Display the result of submitting a form response.""" + response_id: str = str(data.get("uuid") or data.get("id") or "N/A") + print_success(f'Form response submitted for "{form_name}"') + print_info(f"Response ID: {response_id}") + + +def print_kudos_result(receiver_name: str, data: dict[str, Any]) -> None: + """Display the result of giving kudos.""" + kudos_id: str = str(data.get("uuid") or data.get("id") or "N/A") + print_success(f"Kudos sent to {receiver_name}") + print_info(f"Kudos ID: {kudos_id}") + + +def print_users_table(users: list[dict[str, Any]]) -> None: + """Display organization members in a table.""" + if not users: + print_info("No team members found.") + return + + table: Table = Table(title="Team members", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("User UUID", style="dim") + for user in users: + table.add_row( + str(user.get("full_name") or user.get("uuid") or ""), + str(user.get("uuid") or ""), + ) + console.print(table) diff --git a/dailybot_cli/main.py b/dailybot_cli/main.py index 727baea..74a3d20 100644 --- a/dailybot_cli/main.py +++ b/dailybot_cli/main.py @@ -7,12 +7,16 @@ from dailybot_cli import __version__ from dailybot_cli.commands.agent import agent from dailybot_cli.commands.auth import login, logout +from dailybot_cli.commands.checkin import checkin from dailybot_cli.commands.config import config +from dailybot_cli.commands.form import form from dailybot_cli.commands.interactive import run_interactive +from dailybot_cli.commands.kudos import kudos from dailybot_cli.commands.status import status from dailybot_cli.commands.uninstall import uninstall from dailybot_cli.commands.update import update from dailybot_cli.commands.upgrade import upgrade +from dailybot_cli.commands.user import user from dailybot_cli.commands.version import version from dailybot_cli.config import set_api_url_override @@ -63,6 +67,10 @@ def cli(ctx: click.Context, api_url: str | None) -> None: cli.add_command(logout) cli.add_command(update) cli.add_command(status) +cli.add_command(checkin) +cli.add_command(form) +cli.add_command(kudos) +cli.add_command(user) cli.add_command(agent) cli.add_command(config) cli.add_command(version) diff --git a/docker/custom_commands.sh b/docker/custom_commands.sh index 1abf0c4..581abb0 100644 --- a/docker/custom_commands.sh +++ b/docker/custom_commands.sh @@ -240,31 +240,310 @@ function dev_install() { # under /tmp/clitest.* until you `clitest clean` (or `clean --all`). # # Subcommands: -# clitest Build + create + activate (default action). -# clitest list Show all envs with index, age, size; mark active/latest. -# clitest exec [N|PATH] Re-attach to an env (default: latest). -# clitest clean Deactivate + delete the current env. -# clitest clean --all Nuke every /tmp/clitest.* on the box. -# clitest help Long help. +# clitest [--env local] [--port N] [--api-url URL] Build + activate (default: live). +# clitest refresh Rebuild wheel + reinstall in active venv. +# clitest env Show default/local/custom options. +# clitest-local [--port N] Shortcut for --env local. +# +# Defaults: live API (https://api.dailybot.com). Local uses djangovscode:8000 in the +# devcontainer; override host/port via flags or CLITEST_LOCAL_* in cli/.env. +# Private URLs (dev/staging) → pass --api-url explicitly (never hardcoded here). # ============================================================================ CLITEST_BASE_DIR="${CLITEST_BASE_DIR:-/tmp}" CLITEST_PREFIX="clitest" CLITEST_LATEST_LINK="${CLITEST_BASE_DIR}/${CLITEST_PREFIX}.latest" +CLITEST_LIVE_API_URL="${CLITEST_LIVE_API_URL:-https://api.dailybot.com}" +# Persistent credential stores (under the dailybot_data volume in the devcontainer). +# Live clitest uses the CLI default (~/.config/dailybot → ~/.dailybot_data/config_dailybot). +CLITEST_LOCAL_CONFIG_DIR="${CLITEST_LOCAL_CONFIG_DIR:-${HOME}/.dailybot_data/config_local}" +CLITEST_CUSTOM_CONFIG_BASE="${CLITEST_CUSTOM_CONFIG_BASE:-${HOME}/.dailybot_data/config_custom}" +CLITEST_API_URL="" +CLITEST_ENV_NAME="" +CLITEST_LOCAL_HOST_FLAG="" +CLITEST_LOCAL_PORT_FLAG="" +CLITEST_RESOLVED_API_URL="" +CLITEST_RESOLVED_ENV_NAME="" +CLITEST_ARGS=() + +function _clitest_local_api_url() { + local host port + host="${CLITEST_LOCAL_HOST_FLAG:-${CLITEST_LOCAL_HOST:-djangovscode}}" + port="${CLITEST_LOCAL_PORT_FLAG:-${CLITEST_LOCAL_PORT:-8000}}" + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + print.error "Invalid --port: must be a number (got: $port)" + return 1 + fi + echo "http://${host}:${port}" +} + +function _clitest_parse_global_flags() { + CLITEST_API_URL="" + CLITEST_ENV_NAME="" + CLITEST_LOCAL_HOST_FLAG="" + CLITEST_LOCAL_PORT_FLAG="" + CLITEST_ARGS=() + while [[ $# -gt 0 ]]; do + case "$1" in + --api-url) + if [[ -z "${2:-}" ]]; then + print.error "--api-url requires a URL argument." + return 2 + fi + CLITEST_API_URL="$2" + shift 2 + ;; + --api-url=*) + CLITEST_API_URL="${1#*=}" + shift + ;; + --env|--environment|-e) + if [[ -z "${2:-}" ]]; then + print.error "--env requires a name (only 'local' is built-in)." + return 2 + fi + CLITEST_ENV_NAME="${2,,}" + shift 2 + ;; + --env=*|--environment=*) + CLITEST_ENV_NAME="${1#*=}" + CLITEST_ENV_NAME="${CLITEST_ENV_NAME,,}" + shift + ;; + --port|-p) + if [[ -z "${2:-}" ]]; then + print.error "--port requires a port number." + return 2 + fi + CLITEST_LOCAL_PORT_FLAG="$2" + shift 2 + ;; + --port=*) + CLITEST_LOCAL_PORT_FLAG="${1#*=}" + shift + ;; + --local-host) + if [[ -z "${2:-}" ]]; then + print.error "--local-host requires a hostname." + return 2 + fi + CLITEST_LOCAL_HOST_FLAG="$2" + shift 2 + ;; + --local-host=*) + CLITEST_LOCAL_HOST_FLAG="${1#*=}" + shift + ;; + *) + CLITEST_ARGS+=("$1") + shift + ;; + esac + done +} + +function _clitest_resolve_api_target() { + CLITEST_RESOLVED_API_URL="" + CLITEST_RESOLVED_ENV_NAME="" + + if [[ -n "$CLITEST_API_URL" && -n "$CLITEST_ENV_NAME" ]]; then + print.error "Use either --env local or --api-url, not both." + return 2 + fi + + if [[ -n "$CLITEST_LOCAL_PORT_FLAG" || -n "$CLITEST_LOCAL_HOST_FLAG" ]] \ + && [[ "$CLITEST_ENV_NAME" != "local" ]]; then + print.error "--port and --local-host require --env local." + return 2 + fi + + if [[ -n "$CLITEST_ENV_NAME" ]]; then + if [[ "$CLITEST_ENV_NAME" != "local" ]]; then + print.error "Unknown environment: $CLITEST_ENV_NAME" + echo " Built-in: local (--env local or clitest-local)" + echo " Default: live (omit flags, or run plain 'clitest')" + echo " Custom: clitest --api-url https://your-api.example.com" + return 2 + fi + local local_url="" + local_url="$(_clitest_local_api_url)" || return 2 + CLITEST_RESOLVED_API_URL="$local_url" + CLITEST_RESOLVED_ENV_NAME="local" + elif [[ -n "$CLITEST_API_URL" ]]; then + CLITEST_RESOLVED_API_URL="$CLITEST_API_URL" + CLITEST_RESOLVED_ENV_NAME="custom" + else + CLITEST_RESOLVED_API_URL="$CLITEST_LIVE_API_URL" + CLITEST_RESOLVED_ENV_NAME="live" + fi +} + +function _clitest_normalize_api_url() { + local url="${1%/}" + if [[ ! "$url" =~ ^https?:// ]]; then + print.error "Invalid API URL: must start with http:// or https:// (got: $1)" + return 1 + fi + echo "$url" +} + +function _clitest_resolve_config_dir() { + local env_name="${1:-}" + local api_url="${2:-}" + local normalized="" slug="" + + case "$env_name" in + live) + # CLI default: ~/.config/dailybot (persistent via dailybot_data volume). + echo "" + ;; + local) + echo "$CLITEST_LOCAL_CONFIG_DIR" + ;; + custom) + normalized="$(_clitest_normalize_api_url "$api_url")" || return 1 + slug="$(printf '%s' "$normalized" | sed 's|^https\?://||; s|[^a-zA-Z0-9._-]|_|g')" + echo "${CLITEST_CUSTOM_CONFIG_BASE}/${slug}" + ;; + *) + echo "" + ;; + esac +} + +function _clitest_configure_environment() { + local venv_dir="$1" + local api_url="$2" + local env_name="${3:-}" + local activate_script="$venv_dir/bin/activate" + local hook_marker="clitest dailybot api url hook" + local config_dir="" + + printf '%s\n' "$api_url" > "$venv_dir/.dailybot_api_url" + if [[ -n "$env_name" ]]; then + printf '%s\n' "$env_name" > "$venv_dir/.dailybot_env" + else + rm -f "$venv_dir/.dailybot_env" + fi + + config_dir="$(_clitest_resolve_config_dir "$env_name" "$api_url")" || return 1 + if [[ -n "$config_dir" ]]; then + mkdir -p "$config_dir" + printf '%s\n' "$config_dir" > "$venv_dir/.dailybot_config_dir" + else + rm -f "$venv_dir/.dailybot_config_dir" + fi + + # Python 3.14 venv activate no longer sources bin/activate.d — append directly. + if [[ -f "$activate_script" ]] && ! grep -q "$hook_marker" "$activate_script" 2>/dev/null; then + cat >> "$activate_script" <<'EOF' + +# >>> clitest dailybot api url hook >>> +if [[ -n "${VIRTUAL_ENV:-}" && -f "${VIRTUAL_ENV}/.dailybot_api_url" ]]; then + export DAILYBOT_API_URL="$(tr -d '\n\r' < "${VIRTUAL_ENV}/.dailybot_api_url")" +else + unset DAILYBOT_API_URL +fi +if [[ -n "${VIRTUAL_ENV:-}" && -f "${VIRTUAL_ENV}/.dailybot_env" ]]; then + export CLITEST_DAILYBOT_ENV="$(tr -d '\n\r' < "${VIRTUAL_ENV}/.dailybot_env")" +else + unset CLITEST_DAILYBOT_ENV +fi +if [[ -n "${VIRTUAL_ENV:-}" && -f "${VIRTUAL_ENV}/.dailybot_config_dir" ]]; then + export DAILYBOT_CONFIG_DIR="$(tr -d '\n\r' < "${VIRTUAL_ENV}/.dailybot_config_dir")" + mkdir -p "${DAILYBOT_CONFIG_DIR}" +else + unset DAILYBOT_CONFIG_DIR +fi +# <<< clitest dailybot api url hook <<< +EOF + fi +} + +function _clitest_read_api_url() { + local venv_dir="$1" + if [[ -f "$venv_dir/.dailybot_api_url" ]]; then + tr -d '\n\r' < "$venv_dir/.dailybot_api_url" + fi +} + +function _clitest_read_env_name() { + local venv_dir="$1" + if [[ -f "$venv_dir/.dailybot_env" ]]; then + tr -d '\n\r' < "$venv_dir/.dailybot_env" + fi +} + +function _clitest_env_list() { + local local_url="" + local_url="$(_clitest_local_api_url)" || return 1 + echo "clitest API targets:" + echo "" + printf " %-10s %s\n" "TARGET" "API URL" + printf " %-10s %s\n" "----------" "----------------------------------------------" + printf " %-10s %s (default — plain 'clitest')\n" "live" "$CLITEST_LIVE_API_URL" + printf " %-10s %s\n" "local" "$local_url" + printf " %-10s %s\n" "custom" "--api-url URL" + echo "" + echo "Local overrides:" + echo " --port / -p PORT default port: ${CLITEST_LOCAL_PORT:-8000}" + echo " --local-host HOST default host: ${CLITEST_LOCAL_HOST:-djangovscode}" + echo " CLITEST_LOCAL_PORT / CLITEST_LOCAL_HOST in docker/local/cli/.env" + echo "" + echo "Credential storage (persists across clitest re-runs):" + echo " live ~/.config/dailybot (dailybot_data volume)" + echo " local ${CLITEST_LOCAL_CONFIG_DIR}" + echo " custom URL ${CLITEST_CUSTOM_CONFIG_BASE}/" + echo "" + echo "Examples:" + echo " clitest # live (production API)" + echo " clitest-local # local Django in devcontainer" + echo " clitest-local --port 8600 # local on a custom port" + echo " clitest --api-url https://your-api.example.com" +} function clitest() { - if [[ $# -eq 0 ]]; then - _clitest_create + _clitest_parse_global_flags "$@" || return $? + _clitest_resolve_api_target || return $? + + if [[ ${#CLITEST_ARGS[@]} -eq 0 ]]; then + _clitest_create "${CLITEST_RESOLVED_API_URL:-}" "${CLITEST_RESOLVED_ENV_NAME:-}" return $? fi - local subcmd="$1" - shift + + local subcmd="${CLITEST_ARGS[0]}" case "$subcmd" in - create) _clitest_create "$@" ;; - clean) _clitest_clean "$@" ;; - exec|attach) _clitest_exec "$@" ;; - list|ls) _clitest_list ;; - help|-h|--help) _clitest_help ;; + create) + _clitest_create "${CLITEST_RESOLVED_API_URL:-}" "${CLITEST_RESOLVED_ENV_NAME:-}" + ;; + env) + if [[ ${#CLITEST_ARGS[@]} -eq 1 ]]; then + _clitest_env_list + elif [[ ${#CLITEST_ARGS[@]} -eq 2 && "${CLITEST_ARGS[1],,}" == "local" ]]; then + CLITEST_ENV_NAME="local" + _clitest_resolve_api_target || return 2 + _clitest_create "$CLITEST_RESOLVED_API_URL" "$CLITEST_RESOLVED_ENV_NAME" + else + print.error "Usage: clitest env | clitest env local" + return 2 + fi + ;; + clean) + _clitest_clean "${CLITEST_ARGS[@]:1}" + ;; + refresh|reload|update) + _clitest_refresh + ;; + exec|attach) + _clitest_exec "${CLITEST_RESOLVED_API_URL:-}" "${CLITEST_RESOLVED_ENV_NAME:-}" \ + "${CLITEST_ARGS[@]:1}" + ;; + list|ls) + _clitest_list + ;; + help|-h|--help) + _clitest_help + ;; *) print.error "Unknown subcommand: $subcmd" echo "Run 'clitest help' for usage." @@ -273,47 +552,59 @@ function clitest() { esac } +function clitest-local() { + clitest --env local "$@" +} + function _clitest_help() { cat <<'EOF' clitest — package smoke-test workspace manager. Usage: - clitest [create] Build the wheel, install it in a fresh venv under + clitest [create] [--env local] [--port N] [--local-host HOST] + [--api-url URL] + Build the wheel, install it in a fresh venv under /tmp/clitest.XXXXXX, smoke-test, and ACTIVATE that - venv in your current shell. The previous "latest" - symlink is updated to point at the new env. - - clitest list Show all /tmp/clitest.* envs as a table with index, - creation time, size, and tags ("latest", "active"). - - clitest exec [TARGET] Re-attach (`source ... activate`) to an existing env: - no arg → clitest.latest - integer (1, 2, ...) → that index from `list` - absolute path → that exact venv - Useful when opening a second terminal, or when you - have multiple envs and want to switch between them. - - clitest clean Deactivate the currently-active clitest venv (if you - are inside one) and delete it. If you are NOT inside - one, deletes clitest.latest instead. - clitest clean --all Deactivate (if applicable) and nuke EVERY clitest - venv on the box. The clitest.latest symlink is - removed too. Use this to start fresh. + venv in your current shell. + Default API target: live (https://api.dailybot.com). + DAILYBOT_API_URL is exported on activate. + + clitest env List built-in targets (live, local) and --api-url usage. + + clitest list Show all sandboxes (path, env, API URL, tags). + + clitest exec [--env local | --api-url URL] [--port N] [TARGET] + Re-attach to an existing sandbox (default: latest). + + clitest refresh Rebuild the wheel and reinstall into the active clitest + venv (keeps credentials + API URL). Use this after code + changes instead of running full 'clitest' again. + + clitest clean Deactivate and delete the current sandbox. + clitest clean --all Remove every /tmp/clitest.* sandbox. clitest help Show this help. +Shortcut: + clitest-local [--port N] [--local-host HOST] + Same as: clitest --env local + Inside an active clitest venv your shell prompt shows `(clitest.XXXXXX)` to remind you you're in a sandbox, not the editable /workspace install. Typical flow: - $ clitest # build + create + activate - (clitest.io98bF) $ dailybot --version - (clitest.io98bF) $ dailybot --api-url https://staging.dailybot.com login \ - --email me@dailybot.com + $ clitest # smoke-test against live API + (clitest.io98bF) $ dailybot login --email me@example.com + + $ clitest-local # local Django (djangovscode:8000) + (clitest.io98bF) $ dailybot login --email me@example.com + + # After editing code while still in the clitest venv: + (clitest.io98bF) $ clitest refresh + + $ clitest --api-url https://your-api.example.com # private/staging/dev ... - (clitest.io98bF) $ clitest clean # exit + delete this env - $ clitest list # see remaining envs (other terminals) - $ clitest exec # re-attach to latest in a new terminal + (clitest.io98bF) $ clitest clean EOF } @@ -335,8 +626,59 @@ function _clitest_list_paths() { | grep -v "${CLITEST_PREFIX}\.latest$" } +# clitest sandboxes only contain the installed wheel — packaging tools (build, +# twine) live in the container dev env. Never use the active venv's python for +# wheel builds (breaks `clitest refresh` while the sandbox is activated). +function _clitest_host_python() { + if [[ -n "${CLITEST_BUILD_PYTHON:-}" ]]; then + echo "$CLITEST_BUILD_PYTHON" + return 0 + fi + + local path_without_venv="$PATH" + if [[ -n "${VIRTUAL_ENV:-}" ]]; then + path_without_venv="${PATH//$VIRTUAL_ENV\/bin:/}" + path_without_venv="${path_without_venv//:$VIRTUAL_ENV\/bin/}" + path_without_venv="${path_without_venv//$VIRTUAL_ENV\/bin/}" + fi + + local candidate resolved="" + for candidate in python3 python; do + resolved="$(PATH="$path_without_venv" command -v "$candidate" 2>/dev/null || true)" + if [[ -n "$resolved" ]]; then + echo "$resolved" + return 0 + fi + done + + print.error "Could not find a host Python outside the clitest venv." + echo " Set CLITEST_BUILD_PYTHON to a python with 'build' installed." + return 1 +} + +function _clitest_build_wheel() { + local project_root="$1" + local build_python="" + build_python="$(_clitest_host_python)" || return 1 + + if ! "$build_python" -c "import build" 2>/dev/null; then + print.error "Host Python is missing the 'build' package: $build_python" + echo " Run 'dev_install' or 'pip install build' in the container dev env." + return 1 + fi + + (cd "$project_root" && rm -rf dist/ build/ && "$build_python" -m build) +} + +function _clitest_find_wheel() { + local project_root="$1" + ls "$project_root"/dist/dailybot_cli-*-py3-none-any.whl 2>/dev/null | head -n1 +} + function _clitest_create() { - local project_root venv_dir wheel + local api_url="${1:-}" + local env_name="${2:-}" + local project_root venv_dir wheel normalized_api_url="" project_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")" # If currently in a non-clitest venv, refuse — don't trample user's env. @@ -355,7 +697,7 @@ function _clitest_create() { venv_dir="$(mktemp -d -t "${CLITEST_PREFIX}.XXXXXX")" print.success "1/5 — Building sdist + wheel..." - (cd "$project_root" && rm -rf dist/ build/ && python -m build) || { + _clitest_build_wheel "$project_root" || { print.error "⚠️ Build failed." rm -rf "$venv_dir" return 1 @@ -369,9 +711,11 @@ function _clitest_create() { } print.success "3/5 — Creating clean venv at $venv_dir ..." - python -m venv "$venv_dir" || { rm -rf "$venv_dir"; return 1; } + local host_python="" + host_python="$(_clitest_host_python)" || { rm -rf "$venv_dir"; return 1; } + "$host_python" -m venv "$venv_dir" || { rm -rf "$venv_dir"; return 1; } - wheel="$(ls "$project_root"/dist/dailybot_cli-*-py3-none-any.whl 2>/dev/null | head -n1)" + wheel="$(_clitest_find_wheel "$project_root")" if [ -z "$wheel" ]; then print.error "⚠️ No wheel found under dist/." rm -rf "$venv_dir" @@ -393,7 +737,7 @@ function _clitest_create() { return 1 fi local subcommand - for subcommand in login logout status update config agent; do + for subcommand in login logout status update config agent checkin form kudos; do if ! "$venv_dir/bin/dailybot" "$subcommand" --help >/dev/null 2>&1; then print.error "⚠️ '$subcommand --help' failed — wheel may be missing files." rm -rf "$venv_dir" @@ -401,6 +745,14 @@ function _clitest_create() { fi done + if [[ -n "$api_url" ]]; then + normalized_api_url="$(_clitest_normalize_api_url "$api_url")" || { + rm -rf "$venv_dir" + return 1 + } + _clitest_configure_environment "$venv_dir" "$normalized_api_url" "$env_name" + fi + # Update the "latest" symlink so other terminals can `clitest exec` into it. rm -f "$CLITEST_LATEST_LINK" ln -s "$venv_dir" "$CLITEST_LATEST_LINK" @@ -409,6 +761,20 @@ function _clitest_create() { print.success "✅ Package built and verified — entering venv now..." echo " Path: $venv_dir" echo " dailybot: $("$venv_dir/bin/dailybot" --version 2>&1 | head -1)" + if [[ -n "$env_name" ]]; then + echo " Environment: $env_name" + fi + if [[ -n "$normalized_api_url" ]]; then + echo " API URL: $normalized_api_url" + echo " dailybot uses DAILYBOT_API_URL automatically in this venv." + local creds_path="" + creds_path="$(_clitest_resolve_config_dir "$env_name" "$normalized_api_url")" + if [[ -n "$creds_path" ]]; then + echo " Credentials: $creds_path" + else + echo " Credentials: ~/.config/dailybot (persistent)" + fi + fi echo "" echo " Run 'clitest clean' when done, or 'clitest list' to see all envs." echo "" @@ -418,6 +784,54 @@ function _clitest_create() { source "$venv_dir/bin/activate" } +function _clitest_refresh() { + local venv_dir project_root wheel + if ! venv_dir="$(_clitest_active_venv)"; then + print.error "Not in a clitest venv." + echo " Run 'clitest' or 'clitest-local' first, or 'clitest exec' to re-attach." + echo " For instant code reload without a sandbox, use: dev_install (pip install -e \".[dev]\")" + return 1 + fi + + project_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")" + + print.success "1/3 — Building wheel..." + _clitest_build_wheel "$project_root" || { + print.error "⚠️ Build failed." + return 1 + } + + wheel="$(_clitest_find_wheel "$project_root")" + if [[ -z "$wheel" ]]; then + print.error "⚠️ No wheel found under dist/." + return 1 + fi + + print.success "2/3 — Reinstalling into $venv_dir ..." + "$venv_dir/bin/pip" install --force-reinstall "$wheel" --quiet || { + print.error "⚠️ Wheel reinstall failed." + return 1 + } + + print.success "3/3 — Verifying..." + if ! "$venv_dir/bin/dailybot" --version >/dev/null 2>&1; then + print.error "⚠️ dailybot --version failed after refresh." + return 1 + fi + + echo "" + print.success "✅ Refreshed — same venv, same login session." + echo " Path: $venv_dir" + echo " Version: $("$venv_dir/bin/dailybot" --version 2>&1 | head -1)" + if [[ -n "${DAILYBOT_API_URL:-}" ]]; then + echo " API URL: $DAILYBOT_API_URL" + fi + if [[ -n "${DAILYBOT_CONFIG_DIR:-}" ]]; then + echo " Config: $DAILYBOT_CONFIG_DIR" + fi + echo "" +} + function _clitest_clean() { if [[ "${1:-}" == "--all" ]]; then if _clitest_active_venv >/dev/null; then @@ -468,7 +882,14 @@ function _clitest_clean() { } function _clitest_exec() { - local target="${1:-}" venv_path + local api_url="${1:-}" + local env_name="${2:-}" + shift 2 + local target="${1:-}" venv_path normalized_api_url="" stored_api_url="" stored_env_name="" + + if [[ -n "$api_url" ]]; then + normalized_api_url="$(_clitest_normalize_api_url "$api_url")" || return 1 + fi if [[ -z "$target" ]]; then if [[ ! -L "$CLITEST_LATEST_LINK" ]]; then @@ -498,12 +919,25 @@ function _clitest_exec() { return 0 fi + if [[ -n "$normalized_api_url" ]]; then + _clitest_configure_environment "$venv_path" "$normalized_api_url" "$env_name" + fi + + stored_api_url="$(_clitest_read_api_url "$venv_path")" + stored_env_name="$(_clitest_read_env_name "$venv_path")" + # In some other venv (clitest or not) — leave it first if [[ -n "${VIRTUAL_ENV:-}" ]]; then deactivate 2>/dev/null || true fi print.success "Activating $venv_path ..." + if [[ -n "$stored_env_name" ]]; then + echo " Environment: $stored_env_name" + fi + if [[ -n "$stored_api_url" ]]; then + echo " API URL: $stored_api_url" + fi # shellcheck disable=SC1091 source "$venv_path/bin/activate" } @@ -515,17 +949,28 @@ function _clitest_list() { fi current="$(_clitest_active_venv 2>/dev/null || true)" - printf "%-3s %-44s %-8s %-8s %s\n" "#" "Path" "Created" "Size" "Tags" - printf '%s\n' "----------------------------------------------------------------------------------" + printf "%-3s %-28s %-8s %-5s %-8s %-18s %s\n" \ + "#" "Path" "Created" "Size" "Env" "API URL" "Tags" + printf '%s\n' "--------------------------------------------------------------------------------------------------------------" while IFS= read -r path; do [[ -z "$path" ]] && continue count=$((count + 1)) - local created size tags="" + local created size tags="" api_display="" api_url="" env_display="" created="$(date -r "$path" '+%H:%M:%S' 2>/dev/null || echo "?")" size="$(du -sh "$path" 2>/dev/null | cut -f1)" + api_url="$(_clitest_read_api_url "$path")" + env_display="$(_clitest_read_env_name "$path")" + [[ -z "$env_display" ]] && env_display="-" + if [[ -n "$api_url" ]]; then + api_display="${api_url#http://}" + api_display="${api_display#https://}" + else + api_display="-" + fi [[ "$path" == "$latest_target" ]] && tags="${tags}latest " [[ "$path" == "$current" ]] && tags="${tags}active" - printf "%-3s %-44s %-8s %-8s %s\n" "$count" "$path" "$created" "$size" "${tags% }" + printf "%-3s %-28s %-8s %-5s %-8s %-18s %s\n" \ + "$count" "$path" "$created" "$size" "$env_display" "$api_display" "${tags% }" done < <(_clitest_list_paths) if [[ $count -eq 0 ]]; then @@ -709,12 +1154,16 @@ function show_welcome() { echo " • dev_install - pip install -e \".[dev]\" (one-shot)" echo "" echo "Package release smoke test (clitest — sandbox manager):" - echo " • clitest - build wheel → fresh venv → smoke-test → drop you inside" - echo " • clitest list - table of available envs (#, path, time, size, tags)" - echo " • clitest exec [N|PATH] - re-attach to an existing env (default: latest)" - echo " • clitest clean - deactivate + remove the current env" - echo " • clitest clean --all - remove every /tmp/clitest.*" - echo " • clitest help - detailed help" + echo " • clitest - build wheel → venv → smoke-test → live API (default)" + echo " • clitest-local [--port N] - same, pinned to local Django (djangovscode:8000)" + echo " • clitest --api-url URL - same, pinned to a custom API base URL" + echo " • clitest env - show live/local/custom targets" + echo " • clitest list - table of sandboxes (#, env, API URL, tags)" + echo " • clitest exec [N|PATH] - re-attach to an existing sandbox" + echo " • clitest refresh - rebuild wheel + reinstall in active venv" + echo " • clitest clean - deactivate + remove the current sandbox" + echo " • clitest clean --all - remove every /tmp/clitest.*" + echo " • clitest help - detailed help" echo "" echo "AI Assistant commands:" echo " • claude - Claude Code CLI" diff --git a/docker/local/cli/.env.example b/docker/local/cli/.env.example index 22c839e..3410c23 100644 --- a/docker/local/cli/.env.example +++ b/docker/local/cli/.env.example @@ -21,3 +21,18 @@ PYPI_API_TOKEN= TESTPYPI_API_TOKEN= + +# --------------------------------------------------------------------------- +# clitest local API (optional overrides) +# --------------------------------------------------------------------------- +# Used by `clitest-local` / `clitest --env local`. Defaults: djangovscode:8000 +# on the Dailybot devcontainer network. Override only if your local Django +# stack listens elsewhere. For staging/dev/private APIs use: +# clitest --api-url https://your-api.example.com +# +# CLITEST_LOCAL_HOST=djangovscode +# CLITEST_LOCAL_PORT=8000 +# +# Optional persistent credential paths (defaults shown): +# CLITEST_LOCAL_CONFIG_DIR=${HOME}/.dailybot_data/config_local +# CLITEST_CUSTOM_CONFIG_BASE=${HOME}/.dailybot_data/config_custom diff --git a/docker/local/cli/entrypoint.sh b/docker/local/cli/entrypoint.sh index 01de80e..a56acfa 100755 --- a/docker/local/cli/entrypoint.sh +++ b/docker/local/cli/entrypoint.sh @@ -186,6 +186,8 @@ setup_dailybot_persistence_for_user() { # Ensure the persistent data directory exists mkdir -p "${DAILYBOT_DATA_DIR}" + mkdir -p "${DAILYBOT_DATA_DIR}/config_local" + mkdir -p "${DAILYBOT_DATA_DIR}/config_custom" # Handle .config/dailybot directory (auth tokens and config) mkdir -p "${USER_HOME}/.config" diff --git a/docker/local/docker-compose.yaml b/docker/local/docker-compose.yaml index d9bb479..0796261 100644 --- a/docker/local/docker-compose.yaml +++ b/docker/local/docker-compose.yaml @@ -14,6 +14,8 @@ services: command: sleep infinity environment: - DOCKER_DEV_ENV=vscode + networks: + - dailybot env_file: - cli/.env volumes: @@ -35,5 +37,10 @@ volumes: claude_data: {} codex_data: {} cursor_data: {} # Cursor CLI session/auth (symlinked to ~/.cursor) - dailybot_data: {} # Dailybot CLI config/auth (symlinked to ~/.config/dailybot) + dailybot_data: {} # Dailybot CLI config/auth (~/.config/dailybot, config_local, config_custom) gh_data: {} + +networks: + dailybot: + name: dailybot-project-network + external: true diff --git a/docs/AI_AGENT_ONBOARDING.md b/docs/AI_AGENT_ONBOARDING.md index 375cbc9..5318229 100644 --- a/docs/AI_AGENT_ONBOARDING.md +++ b/docs/AI_AGENT_ONBOARDING.md @@ -35,15 +35,19 @@ Both should run without error. If `dailybot` isn't on PATH, the user is probably ## Step 3 — Read the Source -The codebase is small (~6 files in `dailybot_cli/` excluding `commands/`, plus 7 command modules). Read them in this order: +The codebase is small (~6 files in `dailybot_cli/` excluding `commands/`, plus ~12 command modules). Read them in this order: 1. `dailybot_cli/main.py` — entry point, root group 2. `dailybot_cli/api_client.py` — every HTTP endpoint the CLI hits 3. `dailybot_cli/config.py` — credential and profile management 4. `dailybot_cli/display.py` — every output helper -5. `dailybot_cli/commands/*.py` — one file per command/group; agent.py is the largest +5. `dailybot_cli/commands/agent.py` — the largest module (agent subcommands) +6. `dailybot_cli/commands/public_api_helpers.py` — shared auth/error/UX helpers for user-scoped commands +7. `dailybot_cli/commands/user_scoped_actions.py` — shared action logic for checkin/form/user +8. `dailybot_cli/commands/{checkin,form,kudos,user}.py` — thin Click wrappers for user-scoped features +9. `dailybot_cli/commands/interactive.py` — grouped TUI menu -Then skim `tests/` to understand the mocking patterns. +Then skim `tests/` to understand the mocking patterns. Note that user-scoped commands are tested in `tests/public_api_commands_test.py`. ## Step 4 — Read the Docs diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2294a20..a2be7b6 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -73,6 +73,87 @@ Persists to `~/.config/dailybot/config.json` (`0o600`). --- +### `dailybot checkin` (group) — user-scoped, Bearer auth + +#### `dailybot checkin list [--json]` + +Lists today's pending check-ins. Calls `GET /v1/cli/status/`. JSON mode adds 0-based `index` to each question. + +#### `dailybot checkin complete [-a index=response]... [--response-date YYYY-MM-DD] [--yes] [--json]` + +Completes a pending check-in. + +| Flag | Short | Notes | +|------|-------|-------| +| `--answer` | `-a` | Repeatable `index=response` (0-based). Prompts when omitted. | +| `--response-date` | | Defaults to today. | +| `--yes` | `-y` | Skip confirmation prompt. | +| `--json` | | Machine-readable JSON output. | + +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 form` (group) — user-scoped, Bearer auth + +#### `dailybot form list [--json]` + +Lists forms visible to the user. Calls `GET /v1/forms/?include=questions` to include question definitions. + +#### `dailybot form submit [--content JSON] [--yes] [--json]` + +Submits a form response. When `--content` is omitted, calls `GET /v1/forms//` to load questions and prompts each one interactively with type-aware inputs. + +| Flag | Short | Notes | +|------|-------|-------| +| `--content` | `-c` | JSON map `{"": ""}`. | +| `--yes` | `-y` | Skip confirmation prompt. | +| `--json` | | Machine-readable JSON output. | + +--- + +### `dailybot kudos` (group) — user-scoped, Bearer auth + +#### `dailybot kudos give --to --message [--value ] [--yes] [--json]` + +Gives kudos to a teammate. Resolves receivers by name (exact then partial match) against `GET /v1/users/`. + +| Flag | Short | Notes | +|------|-------|-------| +| `--to` | `-t` | Receiver full name or UUID. Required. | +| `--message` | `-m` | Kudos message. Required. | +| `--value` | | Optional company value UUID. | +| `--yes` | `-y` | Skip confirmation prompt. | +| `--json` | | Machine-readable JSON output. | + +Self-kudos is rejected client-side (exit code 4). Ambiguous name matches return exit code 2. + +--- + +### `dailybot user` (group) — user-scoped, Bearer auth + +#### `dailybot user list [--json]` + +Lists organization members. Calls `GET /v1/users/` with automatic pagination (capped at `_MAX_LIST_PAGES = 50`). Table displays Name and UUID only — emails are not shown. + +--- + +### User-scoped exit codes + +All user-scoped commands (`checkin`, `form`, `kudos`, `user`) share these exit codes: + +| Code | Constant | Meaning | +|------|----------|---------| +| `0` | — | Success | +| `2` | `EXIT_USAGE_ERROR` | Invalid input | +| `3` | `EXIT_NOT_AUTHENTICATED` | Not logged in | +| `4` | `EXIT_PERMISSION_DENIED` | Forbidden / self-kudos / daily limit | +| `5` | `EXIT_QUOTA_EXHAUSTED` | Form quota (402) | +| `6` | `EXIT_RATE_LIMITED` | Rate limited (429) | +| `7` | `EXIT_USER_ABORTED` | Confirmation declined | + +--- + ### `dailybot agent` (group) Group-level flag: `--profile / -p ` (passed via `ctx.obj`). @@ -160,6 +241,17 @@ All endpoints are POSTed to `{api_url}/v1/...`. The default `api_url` is `https: | `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}] }` | | +### User-scoped (Bearer) + +| Method | Path | Request | Response | Notes | +|--------|------|---------|----------|-------| +| `GET` | `/v1/forms/` | `?include=questions` (optional) | `[{ id, name, questions?: [...] }]` | | +| `GET` | `/v1/forms//` | — | `{ id, name, questions: [{ uuid, question, question_type, choices? }] }` | Used by guided form submit | +| `POST` | `/v1/forms//responses/` | `{ content: { "": "" } }` | `{ uuid }` | 402 = quota exhausted | +| `POST` | `/v1/checkins//responses/` | `{ responses: [{ uuid, index, response }], last_question_index?, response_date? }` | `{ uuid }` | | +| `GET` | `/v1/users/` | — | `{ results: [{ uuid, full_name }], next: url\|null }` | Paginated | +| `POST` | `/v1/kudos/` | `{ receivers: [""], content, company_value? }` | `{ uuid }` | 406 = daily limit | + ### Agent (X-API-KEY *or* Bearer) | Method | Path | Request | Response | Notes | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5506690..887571e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,7 +13,8 @@ The Dailybot CLI is a **thin Click-based wrapper** around the Dailybot HTTP API. ▼ ▼ ┌──────────────────────────────────────────────┐ ┌────────────────────────┐ │ dailybot_cli/commands/*.py │ │ commands/interactive.py│ -│ (auth, status, update, agent, config) │ │ questionary-driven │ +│ (auth, status, update, agent, config, │ │ questionary-driven │ +│ checkin, form, kudos, user) │ │ grouped TUI menu │ │ • Parse args, validate flags │ │ TUI; calls the same │ │ • Resolve auth context (profile / token) │ │ client + display │ │ • Wrap APIError → user-friendly message │ │ helpers │ @@ -35,6 +36,7 @@ The Dailybot CLI is a **thin Click-based wrapper** around the Dailybot HTTP API. │ Dailybot API │ │ /v1/cli/auth/{request-code,verify-code,…} │ │ /v1/cli/{updates,status} │ +│ /v1/{checkins,forms,users,kudos} │ │ /v1/agent-{reports,health,messages,email, │ │ webhook} │ │ /v1/agent/register/{challenge,} │ @@ -44,7 +46,7 @@ The Dailybot CLI is a **thin Click-based wrapper** around the Dailybot HTTP API. ## Module Responsibilities ### `dailybot_cli/main.py` -The entry point. Defines the root `cli` Click group, the `--api-url` override, and `--version`. Registers the top-level commands (`login`, `logout`, `update`, `status`, `agent`, `config`). When invoked with no subcommand, drops into `commands/interactive.py::run_interactive`. +The entry point. Defines the root `cli` Click group, the `--api-url` override, and `--version`. Registers the top-level commands (`login`, `logout`, `update`, `status`, `agent`, `config`, `checkin`, `form`, `kudos`, `user`). When invoked with no subcommand, drops into `commands/interactive.py::run_interactive`. **Key contract:** `--api-url` calls `set_api_url_override(...)` *before* any subcommand runs, so every subsequent `DailyBotClient()` picks up the overridden URL. The override is also exposed via `DAILYBOT_API_URL` env var. @@ -57,7 +59,7 @@ The single HTTP boundary. Owns: - `_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. -**Two different auth schemes.** Human endpoints (`/v1/cli/*`) only accept Bearer tokens. Agent endpoints (`/v1/agent*/*`) accept either. The split is intentional and reflects the platform's security model. +**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 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. @@ -88,7 +90,7 @@ The user-facing rendering layer. Two distinct `rich.Console` instances: Every command callback rendering output should go through one of: - `print_success`, `print_error`, `print_warning`, `print_info` -- Specialized helpers: `print_auth_status`, `print_pending_checkins`, `print_agent_health`, `print_agent_messages`, `print_agent_message_sent`, `print_agent_email_sent`, `print_agent_profiles`, `print_registration_result`, `print_update_result`, `print_pending_agent_messages`, `print_webhook_result` +- Specialized helpers: `print_auth_status`, `print_pending_checkins`, `print_agent_health`, `print_agent_messages`, `print_agent_message_sent`, `print_agent_email_sent`, `print_agent_profiles`, `print_registration_result`, `print_update_result`, `print_pending_agent_messages`, `print_webhook_result`, `print_users_table`, `print_forms_table`, `print_checkin_list`, `print_kudos_result`, `print_form_submit_result` **Why stderr matters.** Users pipe CLI output into other tools (`dailybot agent message list | jq …`). Errors going to stderr mean failures are visible without polluting the data stream. @@ -117,8 +119,14 @@ Specific notes per file: - **`status.py`** — two modes: list pending check-ins (default) or verify auth (`--auth`). The `--auth` path tries OTP first, then API key, with carefully tuned error messages. - **`update.py`** — supports free-text + structured fields. When invoked with no args, falls back to a stdin loop (Enter twice to submit). 401/403 → `dailybot login`; 400 with "ai processing failed" → contact support. - **`agent.py`** — the largest module. Sub-groups: `webhook`, `message`, `email`. The `_resolve_agent_context` helper centralizes the 5-step auth resolution and is the only function that should be touched if the resolution order needs to change. The `register` command implements a math-challenge handshake (no auth needed). +- **`checkin.py`** — thin Click group (`list`, `complete`). Delegates to `user_scoped_actions.py` for all logic. +- **`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. +- **`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 four-choice menu. +- **`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. ## Data Flow Examples @@ -178,8 +186,11 @@ 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 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) | | A new env var read | `dailybot_cli/config.py` resolution helper, never inlined elsewhere | -| A test | `tests/_test.py` (file name mirrors the module under test) | +| A test for agent commands | `tests/commands_test.py` | +| A test for user-scoped commands | `tests/public_api_commands_test.py` | +| A test for `api_client.py` | `tests/api_client_test.py` | diff --git a/docs/CLI_COMMAND_BEST_PRACTICES.md b/docs/CLI_COMMAND_BEST_PRACTICES.md index c7bd954..169a446 100644 --- a/docs/CLI_COMMAND_BEST_PRACTICES.md +++ b/docs/CLI_COMMAND_BEST_PRACTICES.md @@ -51,8 +51,12 @@ Anything that doesn't fit this shape is either: | `--profile` | `-p` | Profile slug | | `--metadata` | `-d` | JSON metadata | | `--json-data` | `-j` | Structured data | -| `--milestone` | `-m` | Milestone marker | -| `--co-authors` | `-c` | Repeatable co-authors | +| `--milestone` | `-m` | Milestone marker (agent); message (kudos) | +| `--co-authors` | `-c` | Repeatable co-authors (agent); content (form) | +| `--answer` | `-a` | Repeatable check-in answers (index=response) | +| `--to` | `-t` | Kudos receiver (name or UUID) | +| `--yes` | `-y` | Skip confirmation prompt (user-scoped) | +| `--json` | | Machine-readable JSON output (user-scoped) | Pick the same short alias every time you re-use the same long name. If you need a new short alias, check the list above first to avoid collisions inside the same command. @@ -122,9 +126,10 @@ 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` | | 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. +**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. For the resolution order specifics, see [CONFIGURATION.md](CONFIGURATION.md). @@ -153,15 +158,26 @@ raise SystemExit(1) Avoid `sys.exit(...)` and never call `os._exit(...)`. +## Shared Action Modules (User-Scoped Pattern) + +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. +- **`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. + +When adding a new user-scoped command, follow this pattern: thin Click group → delegates to action function in `user_scoped_actions.py` → uses helpers from `public_api_helpers.py`. + ## When to Add a Helper Module -The five command files in `dailybot_cli/commands/` average ~150–650 lines. When a single command is approaching ~250 lines or has multiple distinct responsibilities, consider extracting: +The command files in `dailybot_cli/commands/` average ~150–650 lines. When a single command is approaching ~250 lines or has multiple distinct responsibilities, consider extracting: - A pure-function helper into the same file (lowercase `_helper_name`). - A reusable client method into `api_client.py`. - A reusable display helper into `display.py`. -Avoid creating a `services/` or `domain/` layer prematurely — the architecture is intentionally flat. The biggest module today, `agent.py`, lives within that flat structure cleanly because each subcommand is independent. +Avoid creating a `services/` or `domain/` layer prematurely — the architecture is intentionally flat. The biggest module today, `agent.py`, lives within that flat structure cleanly because each subcommand is independent. The user-scoped commands use `public_api_helpers.py` and `user_scoped_actions.py` as a deliberate two-module split (helpers vs actions) — this is the ceiling of acceptable decomposition. ## When to Add a New Command Group diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 773aacf..11acce2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration & Credentials -The Dailybot CLI persists state in `~/.config/dailybot/`. There is no XDG-spec lookup yet (issue: TODO if/when raised) — the path is hard-coded in `dailybot_cli/config.py`. All files that contain secrets are written with mode `0o600`. +The Dailybot CLI persists state in `~/.config/dailybot/` by default. The path can be overridden by setting `DAILYBOT_CONFIG_DIR` (see Environment Variables below). All files that contain secrets are written with mode `0o600`. ## Files @@ -36,6 +36,7 @@ The Dailybot CLI persists state in `~/.config/dailybot/`. There is no XDG-spec l | Variable | Read by | Effect | |----------|---------|--------| +| `DAILYBOT_CONFIG_DIR` | `get_config_dir()` | Redirects all config/credential file I/O to this directory instead of `~/.config/dailybot/`. Useful for dev sandboxes (`clitest`) and CI environments. | | `DAILYBOT_API_URL` | `get_api_url()` | Overrides the API base URL (after `--api-url` flag) | | `DAILYBOT_API_KEY` | `get_api_key()` | Provides an org API key without storing it on disk | | `DAILYBOT_CLI_TOKEN` | `get_token()` | Provides a login Bearer token without `dailybot login` | @@ -44,9 +45,9 @@ The Dailybot CLI persists state in `~/.config/dailybot/`. There is no XDG-spec l ## Auth Resolution Order -### For `dailybot login` / `logout` / `status` / `update` +### For `dailybot login` / `logout` / `status` / `update` / `checkin` / `form` / `kudos` / `user` -These commands only accept the **login session Bearer token**. Resolution: +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: 1. `DAILYBOT_CLI_TOKEN` env var 2. `credentials.json::token` diff --git a/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md b/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md index ff973d7..77e13ab 100644 --- a/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md +++ b/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md @@ -57,6 +57,12 @@ Don't reinvent panels and tables in command code. The existing helpers cover mos | `print_agent_profiles(profiles)` | Profile table with masked keys | | `print_registration_result(data)` | Registration panel + claim URL | | `print_update_result(data)` | Update receipt with attached follow-ups | +| `print_users_table(users)` | Team members table (Name + UUID, no email) | +| `print_forms_table(forms)` | Forms table (Name + UUID + Questions count) | +| `print_checkin_list(checkins)` | Pending check-ins table with question count | +| `print_kudos_result(name, data)` | Kudos sent confirmation panel | +| `print_form_submit_result(data)` | Form submission confirmation panel | +| `print_checkin_complete_result(data)` | Check-in completion confirmation panel | If you need a new shape, add a helper here rather than building it inline. @@ -98,6 +104,13 @@ Status text is short, imperative, present-continuous. Examples used in the codeb - `"Registering webhook..."` - `"Sending message..."` - `"Marking messages as read..."` +- `"Fetching pending check-ins..."` +- `"Submitting check-in..."` +- `"Fetching forms..."` +- `"Submitting form response..."` +- `"Resolving receiver..."` +- `"Sending kudos..."` +- `"Fetching team members..."` If a call is fast (<100ms typical), a spinner is still preferred — it provides feedback that the CLI is actually doing something even on slow networks. diff --git a/docs/ECOSYSTEM_CONTEXT.md b/docs/ECOSYSTEM_CONTEXT.md index 6c322aa..c151bc7 100644 --- a/docs/ECOSYSTEM_CONTEXT.md +++ b/docs/ECOSYSTEM_CONTEXT.md @@ -43,9 +43,9 @@ The CLI **only** knows two things: Everything else is API-mediated. -## Endpoint Split: Human vs Agent +## Endpoint Split: Human vs User-Scoped vs Agent -The Dailybot API exposes two distinct endpoint families that the CLI consumes: +The Dailybot API exposes three distinct endpoint families that the CLI consumes: ### Human endpoints — `/v1/cli/*` @@ -53,7 +53,15 @@ The Dailybot API exposes two distinct endpoint families that the CLI consumes: - 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). -The CLI's `_headers(authenticated=True)` builds these. +The CLI's `_headers(authenticated=True)` builds these. Used by `login`, `logout`, `status`, `update`. + +### User-scoped public API endpoints — `/v1/{checkins,forms,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. +- 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`. ### Agent endpoints — `/v1/agent*/*` @@ -65,8 +73,9 @@ The CLI's `_agent_headers()` builds these. API key takes precedence over Bearer ### Why the split -- Human accounts are tied to chat platforms (Slack/Teams/Discord users). Bearer tokens carry the user identity for follow-up matching, mentions, etc. -- Agents are organizational identities, not human users. API keys are long-lived, can be rotated independently, and don't tie back to a chat profile. +- **Human** accounts are tied to chat platforms (Slack/Teams/Discord users). Bearer tokens carry the user identity for follow-up matching, mentions, etc. +- **User-scoped** endpoints use the same Bearer token but expose public API resources (forms, check-ins, kudos, user directory). They act as the user — same visibility, same permissions as the webapp. +- **Agents** are organizational identities, not human users. API keys are long-lived, can be rotated independently, and don't tie back to a chat profile. This split is part of the platform's security model and won't change. New CLI features must pick the right side. diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 5b21ac4..f10b3ad 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -32,7 +32,13 @@ It is a **public, open-source product** distributed through PyPI, Homebrew, and | Verify auth | `dailybot status --auth` | Tries OTP login first, then API key | | Submit a free-text update | `dailybot update ""` | Dailybot AI parses and routes to the matching follow-ups | | Submit a structured update | `dailybot update --done X --doing Y --blocked Z` | Bypasses AI parsing | -| Interactive TUI | `dailybot` (no args) | questionary-driven menu: send update, view pending, auth status | +| List pending check-ins | `dailybot checkin list` | Pending follow-ups for today with question details | +| Complete a check-in | `dailybot checkin complete ` | Interactive or `--answer` flags; type-aware prompts | +| List forms | `dailybot form list` | All visible forms with question count | +| Submit a form | `dailybot form submit ` | Guided per-question prompts or `--content` JSON | +| Give kudos | `dailybot kudos give --to "Name"` | Resolves receiver by name or UUID; team-visible | +| List team members | `dailybot user list` | Name + UUID; emails not exposed | +| Interactive TUI | `dailybot` (no args) | Grouped menu: Check-ins, Forms, Team, Session | ### Agent-Facing @@ -71,7 +77,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`), only the login session is supported. +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. For **standalone agent registration**, no authentication is required — agents complete a math challenge to prove they're not bots. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index bf47bd0..def32ae 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -71,11 +71,22 @@ The `org_cache.json` is cleared after a successful verify. - Issued by `verify_code(...)`. - Stored in `credentials.json` with `0o600`. - Sent on every authenticated request as `Authorization: Bearer `. +- Used by human endpoints (`/v1/cli/*`) and user-scoped endpoints (`/v1/checkins/*`, `/v1/forms/*`, `/v1/users/`, `/v1/kudos/`). - Revoked by `dailybot logout` (best-effort `POST /v1/cli/auth/logout/` + local file removal). - Treated as expired/invalid on any 401/403 from a Bearer-mode call → `_handle_response` rewrites the error to "Session expired. Run 'dailybot login' to re-authenticate." There is no automatic refresh. Tokens have a server-defined lifetime; the user is expected to re-run `dailybot login` when prompted. +## User-Scoped Commands — Privacy Considerations + +The user-scoped commands (`checkin`, `form`, `kudos`, `user`) operate within the authenticated user's permissions. Specific security decisions: + +- **`dailybot user list`** — intentionally omits email addresses from both table and JSON output. This is a PII-minimization measure for an open-source CLI. UUIDs are exposed for programmatic use (e.g., `--to ` in kudos). +- **`dailybot kudos give`** — prevents self-kudos client-side. The receiver is resolved by name against the user directory; ambiguous matches are rejected rather than guessed. +- **Pagination safety** — `list_users()` caps at `_MAX_LIST_PAGES = 50` pages to prevent unbounded loops against a misbehaving backend. +- **Confirmation prompts** — `checkin complete`, `form submit`, and `kudos give` show a confirmation before team-visible writes. `--yes` skips it for non-interactive/scripted use. +- **Exit codes** — structured exit codes (2–7) enable safe scripting without parsing error messages. See [API_REFERENCE.md](API_REFERENCE.md) for the full table. + ## API Key Lifecycle - Issued by `dailybot agent register` (returned in the registration response). diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index a610d53..41655c6 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -25,14 +25,20 @@ pytest -s # don't capture stdout (debugging) ``` tests/ -├── __init__.py # empty, just makes the dir importable -├── api_client_test.py # DailyBotClient + APIError -├── commands_test.py # Click commands via CliRunner -└── config_test.py # ~/.config/dailybot/ file management +├── __init__.py # empty, just makes the dir importable +├── api_client_test.py # DailyBotClient + APIError +├── commands_test.py # Click commands via CliRunner (auth, agent, interactive) +├── config_test.py # ~/.config/dailybot/ file management +├── public_api_commands_test.py # User-scoped commands (checkin, form, kudos, user) +└── form_question_types_test.py # Type-aware form prompt logic ``` When adding a new module, mirror it in `tests/`. New test files **MUST** end in `_test.py`. +### User-scoped command tests + +The user-scoped commands (`checkin`, `form`, `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()`). + ## Mocking HTTP The CLI **must never** hit the real Dailybot API in tests. The canonical pattern: diff --git a/tests/agent_init_test.py b/tests/agent_init_test.py index 9fd6983..3c752fc 100644 --- a/tests/agent_init_test.py +++ b/tests/agent_init_test.py @@ -36,22 +36,20 @@ def chdir_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @pytest.fixture def isolated_global_agents(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Fully isolate global state: AGENTS_FILE, CREDENTIALS_FILE, CONFIG_FILE, env vars. + """Fully isolate global state via DAILYBOT_CONFIG_DIR env var. - Without all four, the wizard / nudge tests would read the real ~/.config/dailybot/ + Without this, the wizard / nudge tests would read the real ~/.config/dailybot/ on the developer's machine, making behavior dependent on whether they happen to be logged in. """ - fake_agents: Path = tmp_path / "fake-agents.json" - fake_creds: Path = tmp_path / "fake-credentials.json" - fake_config: Path = tmp_path / "fake-config.json" + monkeypatch.setenv("DAILYBOT_CONFIG_DIR", str(tmp_path)) monkeypatch.setattr("dailybot_cli.config.CONFIG_DIR", tmp_path) - monkeypatch.setattr("dailybot_cli.config.AGENTS_FILE", fake_agents) - monkeypatch.setattr("dailybot_cli.config.CREDENTIALS_FILE", fake_creds) - monkeypatch.setattr("dailybot_cli.config.CONFIG_FILE", fake_config) + monkeypatch.setattr("dailybot_cli.config.AGENTS_FILE", tmp_path / "agents.json") + monkeypatch.setattr("dailybot_cli.config.CREDENTIALS_FILE", tmp_path / "credentials.json") + monkeypatch.setattr("dailybot_cli.config.CONFIG_FILE", tmp_path / "config.json") monkeypatch.delenv("DAILYBOT_API_KEY", raising=False) monkeypatch.delenv("DAILYBOT_CLI_TOKEN", raising=False) - return fake_agents + return tmp_path / "agents.json" # --- write_repo_profile ----------------------------------------------------- diff --git a/tests/api_client_test.py b/tests/api_client_test.py index c41c905..80cc57d 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -116,6 +116,143 @@ def test_get_status(self, client: DailyBotClient) -> None: assert result["count"] == 1 +class TestDailyBotClientPublicApi: + def test_complete_checkin(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"uuid": "response-uuid"} + + responses: list[dict[str, Any]] = [ + {"uuid": "q-0", "index": 0, "response": "Done"}, + ] + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.complete_checkin( + followup_uuid="followup-uuid", + responses=responses, + last_question_index=0, + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"]["responses"] == responses + assert "Bearer test-token" in call_kwargs["headers"]["Authorization"] + assert result["uuid"] == "response-uuid" + + def test_list_forms(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "form-uuid", "name": "Feedback"}] + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_forms() + + assert result[0]["id"] == "form-uuid" + assert "Bearer test-token" in mock_get.call_args[1]["headers"]["Authorization"] + assert mock_get.call_args[1].get("params", {}) == {} + + def test_list_forms_with_questions(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "id": "form-uuid", + "name": "Feedback", + "questions": [{"uuid": "q1", "question": "How was it?"}], + } + ] + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_forms(include_questions=True) + + assert result[0]["questions"][0]["uuid"] == "q1" + assert mock_get.call_args[1]["params"] == {"include": "questions"} + + def test_get_form(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "form-uuid", + "name": "Feedback", + "questions": [ + {"uuid": "question-uuid", "question": "How was your week?"}, + ], + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: dict[str, Any] = client.get_form("form-uuid") + + assert result["id"] == "form-uuid" + assert mock_get.call_args[0][0].endswith("/v1/forms/form-uuid/") + assert "Bearer test-token" in mock_get.call_args[1]["headers"]["Authorization"] + + def test_submit_form_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"uuid": "response-uuid"} + + content: dict[str, str] = {"question-uuid": "Yes"} + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.submit_form_response("form-uuid", content) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"] == {"content": content} + assert result["uuid"] == "response-uuid" + + def test_list_users_paginated(self, client: DailyBotClient) -> None: + first_response: MagicMock = MagicMock(spec=httpx.Response) + first_response.status_code = 200 + first_response.json.return_value = { + "results": [{"uuid": "user-1", "full_name": "Jane Doe"}], + "next": "http://test-api.example.com/v1/users/?page=2", + } + second_response: MagicMock = MagicMock(spec=httpx.Response) + second_response.status_code = 200 + second_response.json.return_value = { + "results": [{"uuid": "user-2", "full_name": "John Doe"}], + "next": None, + } + + with patch("httpx.get", side_effect=[first_response, second_response]) as mock_get: + result: list[dict[str, Any]] = client.list_users() + + assert len(result) == 2 + assert mock_get.call_count == 2 + + def test_list_users_page_cap(self, client: DailyBotClient) -> None: + """Pagination must stop at _MAX_LIST_PAGES even if the backend keeps returning next.""" + from dailybot_cli.api_client import _MAX_LIST_PAGES + + infinite_page: MagicMock = MagicMock(spec=httpx.Response) + infinite_page.status_code = 200 + infinite_page.json.return_value = { + "results": [{"uuid": "user-x"}], + "next": "http://test-api.example.com/v1/users/?page=999", + } + + with patch("httpx.get", return_value=infinite_page) as mock_get: + result: list[dict[str, Any]] = client.list_users() + + assert mock_get.call_count == _MAX_LIST_PAGES + assert len(result) == _MAX_LIST_PAGES + + def test_give_kudos(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "kudos-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.give_kudos( + receivers=["user-uuid"], + content="Great work!", + company_value="value-uuid", + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"]["receivers"] == ["user-uuid"] + assert call_kwargs["json"]["company_value"] == "value-uuid" + assert "by_dailybot" not in call_kwargs["json"] + assert result["uuid"] == "kudos-uuid" + + class TestDailyBotClientAgent: def test_submit_agent_report(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) diff --git a/tests/commands_test.py b/tests/commands_test.py index 3cf96be..d07ef92 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -1031,13 +1031,57 @@ def test_interactive_guides_login_when_not_authenticated( None, {"token": "tok", "email": "u@t.com", "organization": "Org"}, ] - # Mock questionary.select to return "Quit" - mock_questionary.select.return_value.ask.return_value = "Quit" + # Mock questionary.select to return the exit action ID + mock_questionary.select.return_value.ask.return_value = "exit" # Provide email for the prompt (code is handled inside _do_login which is mocked) runner.invoke(cli, [], input="u@t.com\n") mock_do_login.assert_called_once_with("u@t.com") +class TestInteractiveMenu: + @patch("dailybot_cli.commands.interactive.pick_from_list") + @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.execute_kudos_give") + @patch("dailybot_cli.commands.interactive.get_current_user_uuid") + @patch("dailybot_cli.commands.interactive.DailyBotClient") + def test_interactive_give_kudos_picks_teammate( + self, + mock_client_cls: MagicMock, + mock_current_uuid: MagicMock, + mock_execute_kudos: MagicMock, + mock_get_token: MagicMock, + mock_load_creds: MagicMock, + mock_questionary: MagicMock, + mock_pick_from_list: MagicMock, + runner: CliRunner, + ) -> None: + 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_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "self-uuid", "full_name": "Me"}, + {"uuid": "peer-uuid", "full_name": "Jane Doe"}, + ] + mock_questionary.select.return_value.ask.side_effect = ["team.kudos", "exit"] + mock_pick_from_list.return_value = {"uuid": "peer-uuid", "full_name": "Jane Doe"} + mock_questionary.text.return_value.ask.return_value = "Great work!" + + result = runner.invoke(cli, []) + assert result.exit_code == 0 + mock_execute_kudos.assert_called_once() + assert mock_execute_kudos.call_args.args[1] == "peer-uuid" + assert mock_execute_kudos.call_args.args[3] == "Great work!" + assert mock_execute_kudos.call_args.kwargs["assume_yes"] is True + + class TestAgentCommand: @patch("dailybot_cli.commands.agent.get_agent_auth") @patch("dailybot_cli.commands.agent.DailyBotClient") diff --git a/tests/form_question_types_test.py b/tests/form_question_types_test.py new file mode 100644 index 0000000..6ffa0c9 --- /dev/null +++ b/tests/form_question_types_test.py @@ -0,0 +1,27 @@ +"""Tests for form question prompting helpers.""" + +from dailybot_cli.commands.user_scoped_actions import _classify_form_question_type + + +class TestFormQuestionTypes: + def test_classify_text(self) -> None: + assert _classify_form_question_type({"question_type": "text_field"}) == "text" + + def test_classify_numeric(self) -> None: + assert _classify_form_question_type({"question_type": "numeric"}) == "numeric" + assert _classify_form_question_type({"type": "integer"}) == "numeric" + + def test_classify_boolean(self) -> None: + assert _classify_form_question_type({"question_type": "boolean"}) == "boolean" + assert _classify_form_question_type({"question_type": "yes_no"}) == "boolean" + + def test_classify_choice_by_type(self) -> None: + assert _classify_form_question_type({"question_type": "single_choice"}) == "choice" + + def test_classify_choice_by_options(self) -> None: + assert ( + _classify_form_question_type( + {"question_type": "text_field", "choices": ["Low", "High"]}, + ) + == "choice" + ) diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py new file mode 100644 index 0000000..b6646ac --- /dev/null +++ b/tests/public_api_commands_test.py @@ -0,0 +1,565 @@ +"""Tests for user-scoped public API commands (checkin, form, kudos).""" + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError +from dailybot_cli.main import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +STATUS_PAYLOAD: dict[str, Any] = { + "count": 1, + "pending_checkins": [ + { + "followup_name": "Daily Standup", + "followup_uuid": "followup-uuid-1", + "template_questions": [ + { + "uuid": "question-uuid-0", + "question": "What did you complete yesterday?", + "question_type": "text_field", + "is_blocker": False, + }, + { + "uuid": "question-uuid-1", + "question": "What will you do today?", + "question_type": "text_field", + "is_blocker": False, + }, + ], + } + ], +} + + +class TestCheckinCommand: + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_list_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + 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 + assert "followup-uuid-1" in result.output + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_list_json( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_status.return_value = STATUS_PAYLOAD + + result = runner.invoke(cli, ["checkin", "list", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + 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( + self, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = None + result = runner.invoke(cli, ["checkin", "list"]) + assert result.exit_code == 3 + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_list_auth_failure_json( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_status.side_effect = APIError(401, "Unauthorized") + + result = runner.invoke(cli, ["checkin", "list", "--json"]) + assert result.exit_code == 3 + 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.DailyBotClient") + def test_checkin_complete_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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"} + + result = runner.invoke( + cli, + [ + "checkin", + "complete", + "followup-uuid-1", + "-a", + "0=Shipped auth", + "-a", + "1=Reviewing migrations", + "--yes", + ], + ) + assert result.exit_code == 0 + mock_client.complete_checkin.assert_called_once_with( + followup_uuid="followup-uuid-1", + responses=[ + {"uuid": "question-uuid-0", "index": 0, "response": "Shipped auth"}, + {"uuid": "question-uuid-1", "index": 1, "response": "Reviewing migrations"}, + ], + last_question_index=1, + response_date=None, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_complete_json_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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"} + + result = runner.invoke( + cli, + [ + "checkin", + "complete", + "followup-uuid-1", + "-a", + "0=Done", + "-a", + "1=Next", + "--yes", + "--json", + ], + ) + 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.DailyBotClient") + def test_checkin_complete_user_abort( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_status.return_value = STATUS_PAYLOAD + + result = runner.invoke( + cli, + [ + "checkin", + "complete", + "followup-uuid-1", + "-a", + "0=Done", + "-a", + "1=Next", + ], + input="n\n", + ) + assert result.exit_code == 7 + mock_client.complete_checkin.assert_not_called() + + +class TestFormCommand: + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_list_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_forms.return_value = [ + { + "id": "form-uuid-1", + "name": "Team feedback", + "is_active": True, + "privacy": "everyone", + } + ] + + result = runner.invoke(cli, ["form", "list"]) + assert result.exit_code == 0 + 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.DailyBotClient") + def test_form_submit_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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"} + + result = runner.invoke( + cli, + [ + "form", + "submit", + "form-uuid-1", + "--content", + '{"question-uuid-1":"Yes"}', + "--yes", + ], + ) + assert result.exit_code == 0 + mock_client.submit_form_response.assert_called_once_with( + form_uuid="form-uuid-1", + content={"question-uuid-1": "Yes"}, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_guided_prompts( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_form.return_value = { + "id": "form-uuid-1", + "name": "Team feedback", + "questions": [ + {"uuid": "question-uuid-1", "question": "How was your week?"}, + {"uuid": "question-uuid-2", "question": "Any blockers?"}, + ], + } + mock_client.submit_form_response.return_value = {"uuid": "response-uuid"} + + result = runner.invoke( + cli, + ["form", "submit", "form-uuid-1", "--yes"], + input="Great week\nNone\n", + ) + assert result.exit_code == 0 + mock_client.get_form.assert_called_once_with("form-uuid-1") + mock_client.submit_form_response.assert_called_once_with( + form_uuid="form-uuid-1", + content={ + "question-uuid-1": "Great week", + "question-uuid-2": "None", + }, + ) + + @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.DailyBotClient") + def test_form_submit_guided_question_types( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_prompt_answer: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_form.return_value = { + "id": "form-uuid-1", + "questions": [ + {"uuid": "q-text", "question": "Comments?", "question_type": "text_field"}, + {"uuid": "q-num", "question": "Score?", "question_type": "numeric"}, + {"uuid": "q-bool", "question": "Recommend?", "question_type": "boolean"}, + { + "uuid": "q-choice", + "question": "Pick one", + "question_type": "choice", + "choices": ["A", "B"], + }, + ], + } + mock_client.submit_form_response.return_value = {"uuid": "response-uuid"} + mock_prompt_answer.side_effect = ["Looks good", 9, True, "A"] + + result = runner.invoke(cli, ["form", "submit", "form-uuid-1", "--yes"]) + assert result.exit_code == 0 + assert mock_prompt_answer.call_count == 4 + mock_client.submit_form_response.assert_called_once_with( + form_uuid="form-uuid-1", + content={ + "q-text": "Looks good", + "q-num": 9, + "q-bool": True, + "q-choice": "A", + }, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_quota_exhausted( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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") + + result = runner.invoke( + cli, + [ + "form", + "submit", + "form-uuid-1", + "--content", + '{"question-uuid-1":"Yes"}', + "--yes", + ], + ) + assert result.exit_code == 5 + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_rate_limited_json( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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") + + result = runner.invoke( + cli, + [ + "form", + "submit", + "form-uuid-1", + "--content", + '{"question-uuid-1":"Yes"}', + "--yes", + "--json", + ], + ) + assert result.exit_code == 6 + payload: dict[str, Any] = json.loads(result.output) + assert payload["status"] == 429 + + +class TestUserCommand: + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_user_list_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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", + "email": "jane@example.com", + } + ] + + result = runner.invoke(cli, ["user", "list"]) + assert result.exit_code == 0 + 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.DailyBotClient") + def test_user_list_json( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.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"}] + + result = runner.invoke(cli, ["user", "list", "--json"]) + assert result.exit_code == 0 + payload: list[dict[str, Any]] = json.loads(result.output) + assert payload[0]["uuid"] == "user-uuid-1" + + +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.DailyBotClient") + def test_kudos_give_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "user-uuid-1", "full_name": "Jane Doe"}, + ] + mock_client.give_kudos.return_value = {"uuid": "kudos-uuid"} + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--to", + "Jane Doe", + "--message", + "Great work!", + "--yes", + ], + ) + assert result.exit_code == 0 + mock_client.give_kudos.assert_called_once_with( + receivers=["user-uuid-1"], + content="Great work!", + company_value=None, + ) + + @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.DailyBotClient") + def test_kudos_give_self_rejected( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "self-uuid", "full_name": "Me"}, + ] + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--to", + "Me", + "--message", + "Nice", + "--yes", + ], + ) + assert result.exit_code == 4 + 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.DailyBotClient") + def test_kudos_give_daily_limit( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "user-uuid-1", "full_name": "Jane Doe"}, + ] + mock_client.give_kudos.side_effect = APIError(406, "Daily limit reached") + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--to", + "Jane Doe", + "--message", + "Great work!", + "--yes", + ], + ) + 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.DailyBotClient") + def test_kudos_give_ambiguous_receiver( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "user-uuid-1", "full_name": "Jane Doe"}, + {"uuid": "user-uuid-2", "full_name": "Jane Smith"}, + ] + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--to", + "Jane", + "--message", + "Great work!", + "--yes", + ], + ) + assert result.exit_code == 2