From 5b282081e1d11aff67441c1e21e2782f87a2fb24 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 12 Jul 2026 17:03:28 +0000 Subject: [PATCH 1/4] feat(form): add --automation and --anonymous flags to form submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new boolean flags to `dailybot form submit`: - `--automation` — channel notifications show no submitter name (for web-form bridges, CI pipelines, third-party integrations) - `--anonymous` — channel notifications show a random generated name instead of the real user Both flags are optional, independent, and default to false. When combined, automation takes precedence for the channel display. Wired through api_client.submit_form_response → CLI flag → API payload. Updated vendored forms skill docs with the new flags table. Added 5 tests (3 api_client, 2 command-level). Co-authored-by: Cursor --- .agents/skills/dailybot/forms/SKILL.md | 4 ++ dailybot_cli/api_client.py | 10 +++- dailybot_cli/commands/form.py | 25 +++++++++ dailybot_cli/commands/user_scoped_actions.py | 10 +++- tests/api_client_test.py | 49 +++++++++++++++++ tests/public_api_commands_test.py | 56 ++++++++++++++++++++ 6 files changed, 152 insertions(+), 2 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index d4e8a3a..0798fec 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -971,6 +971,10 @@ Always show the complete answer set before sending: | `--state` | `-s` | Optional initial state (workflow forms only). Defaults to the form's initial state. | | `--yes` | `-y` | Skip confirmation. | | `--json` | | Machine-readable JSON output. | +| `--automation` | | Submit as an automation — channel notifications show no submitter name. Use for web-form bridges, CI pipelines, or third-party integrations. (`CLI >= 3.4.0`) | +| `--anonymous` | | Submit anonymously — channel notifications show a random generated name (e.g. "Purple Elephant") instead of the real user. (`CLI >= 3.4.0`) | + +> **`--automation` vs `--anonymous`:** Both are independent booleans. `--automation` hides the submitter entirely; `--anonymous` replaces them with a random name. When combined, `automation` takes precedence for the channel display. Neither flag affects who owns the response in the dashboard — it only controls the notification appearance. After submit, **re-read `current_state` and `allowed_transitions` on the returned payload** to decide the next step. diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 5104f4e..be94cd8 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -858,11 +858,19 @@ def submit_form_response( self, form_uuid: str, content: dict[str, Any], + *, + automation: bool = False, + anonymous: bool = False, ) -> dict[str, Any]: """POST /v1/forms//responses/""" + payload: dict[str, Any] = {"content": content} + if automation: + payload["automation"] = True + if anonymous: + payload["anonymous"] = True response: httpx.Response = httpx.post( f"{self.api_url}/v1/forms/{form_uuid}/responses/", - json={"content": content}, + json=payload, headers=self._headers(), timeout=self.timeout, ) diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index f94da92..f065bd3 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -290,11 +290,32 @@ def form_get(form_uuid: str, json_mode: bool) -> None: ) @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.") +@click.option( + "--automation", + is_flag=True, + default=False, + help=( + "Submit as an automation. The response will appear in channel " + "notifications without any submitter name — useful when " + "forwarding third-party form submissions via integrations." + ), +) +@click.option( + "--anonymous", + is_flag=True, + default=False, + help=( + "Submit anonymously. The response will appear with a random " + "generated name instead of your real name." + ), +) def form_submit( form_uuid: str, content: str | None, assume_yes: bool, json_mode: bool, + automation: bool, + anonymous: bool, ) -> None: """Submit a form response. @@ -309,6 +330,8 @@ def form_submit( dailybot form submit dailybot form submit --content '{"":"Yes"}' dailybot form submit --content '{"":"Answer"}' --yes + dailybot form submit --content '{"":"A"}' --automation + dailybot form submit --content '{"":"A"}' --anonymous """ client = require_auth() content_map = resolve_form_content(client, form_uuid, content) @@ -318,6 +341,8 @@ def form_submit( content_map, assume_yes=assume_yes, json_mode=json_mode, + automation=automation, + anonymous=anonymous, ) diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 50bd5f8..28008f3 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -542,6 +542,8 @@ def execute_form_submit( form_name: str | None = None, assume_yes: bool = False, json_mode: bool = False, + automation: bool = False, + anonymous: bool = False, ) -> None: """Submit a form response.""" resolved_name: str = form_name or form_uuid @@ -556,8 +558,12 @@ def execute_form_submit( summary_lines: list[str] = [ f"Form: {resolved_name}", f"Form UUID: {form_uuid}", - "Answers:", ] + if automation: + summary_lines.append("Mode: automation (no submitter shown in channel)") + if anonymous: + summary_lines.append("Mode: anonymous (random name shown in channel)") + summary_lines.append("Answers:") for question_uuid, answer in content_map.items(): summary_lines.append(f" {question_uuid}: {answer}") @@ -568,6 +574,8 @@ def execute_form_submit( result: dict[str, Any] = client.submit_form_response( form_uuid=form_uuid, content=content_map, + automation=automation, + anonymous=anonymous, ) except APIError as exc: exit_for_api_error(exc, json_mode) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index b7e66c2..f7539c2 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -414,6 +414,55 @@ def test_submit_form_response(self, client: DailyBotClient) -> None: assert call_kwargs["json"] == {"content": content} assert result["uuid"] == "response-uuid" + def test_submit_form_response_automation(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, automation=True + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"] == {"content": content, "automation": True} + assert result["uuid"] == "response-uuid" + + def test_submit_form_response_anonymous(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, anonymous=True + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"] == {"content": content, "anonymous": True} + assert result["uuid"] == "response-uuid" + + def test_submit_form_response_both_flags(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, automation=True, anonymous=True + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"] == { + "content": content, + "automation": True, + "anonymous": True, + } + 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 diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 45ee326..2ecc8ff 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -274,6 +274,58 @@ def test_form_submit_success( mock_client.submit_form_response.assert_called_once_with( form_uuid="form-uuid-1", content={"question-uuid-1": "Yes"}, + automation=False, + anonymous=False, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_automation( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_forms.return_value = [{"id": "f-1", "name": "Feedback"}] + mock_client.submit_form_response.return_value = {"uuid": "r-1"} + + result = runner.invoke( + cli, + ["form", "submit", "f-1", "--content", '{"q":"A"}', "--yes", "--automation"], + ) + assert result.exit_code == 0 + mock_client.submit_form_response.assert_called_once_with( + form_uuid="f-1", + content={"q": "A"}, + automation=True, + anonymous=False, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_anonymous( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_forms.return_value = [{"id": "f-1", "name": "Feedback"}] + mock_client.submit_form_response.return_value = {"uuid": "r-1"} + + result = runner.invoke( + cli, + ["form", "submit", "f-1", "--content", '{"q":"A"}', "--yes", "--anonymous"], + ) + assert result.exit_code == 0 + mock_client.submit_form_response.assert_called_once_with( + form_uuid="f-1", + content={"q": "A"}, + automation=False, + anonymous=True, ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @@ -309,6 +361,8 @@ def test_form_submit_guided_prompts( "question-uuid-1": "Great week", "question-uuid-2": "None", }, + automation=False, + anonymous=False, ) @patch("dailybot_cli.commands.user_scoped_actions._prompt_form_answer") @@ -351,6 +405,8 @@ def test_form_submit_guided_question_types( "q-bool": True, "q-choice": "A", }, + automation=False, + anonymous=False, ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") From 503bcfe2cb399e4472f662827e7aba0358655985 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 12 Jul 2026 19:19:38 +0000 Subject: [PATCH 2/4] feat(form): add --guest-name, --guest-email, --source flags to form submit Extend the automation/anonymous mode with guest user attribution and submission provenance tracking. Client-side validation rejects invalid emails and source strings longer than 512 chars before hitting the API. The guest_user_required error code is now handled with a clear message. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 6 ++ dailybot_cli/commands/form.py | 44 +++++++++++++ dailybot_cli/commands/public_api_helpers.py | 4 ++ dailybot_cli/commands/user_scoped_actions.py | 11 ++++ tests/api_client_test.py | 25 ++++++++ tests/public_api_commands_test.py | 67 ++++++++++++++++++++ 6 files changed, 157 insertions(+) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index be94cd8..b506a63 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -861,6 +861,8 @@ def submit_form_response( *, automation: bool = False, anonymous: bool = False, + guest_user: dict[str, str] | None = None, + submission_source: str | None = None, ) -> dict[str, Any]: """POST /v1/forms//responses/""" payload: dict[str, Any] = {"content": content} @@ -868,6 +870,10 @@ def submit_form_response( payload["automation"] = True if anonymous: payload["anonymous"] = True + if guest_user: + payload["guest_user"] = guest_user + if submission_source: + payload["submission_source"] = submission_source response: httpx.Response = httpx.post( f"{self.api_url}/v1/forms/{form_uuid}/responses/", json=payload, diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index f065bd3..4069760 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -1,5 +1,6 @@ """Form commands for the user-scoped public API.""" +import re from collections.abc import Callable from typing import Any @@ -59,6 +60,9 @@ # The server reuses `invalid_workflow_state` for a malformed `--state # "Label:#color"` during authoring. On this listing it means the form has no # workflow at all, so the shared message would send the user down the wrong path. +MAX_SUBMISSION_SOURCE_LENGTH: int = 512 +_EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + _RESPONSES_ERROR_OVERRIDES: dict[str, str] = { "invalid_workflow_state": ( "This form has no workflow, so --state doesn't apply. Drop the flag, or run " @@ -309,6 +313,21 @@ def form_get(form_uuid: str, json_mode: bool) -> None: "generated name instead of your real name." ), ) +@click.option( + "--guest-name", + default=None, + help="Guest submitter name (used with --automation for third-party submissions).", +) +@click.option( + "--guest-email", + default=None, + help="Guest submitter email (used with --automation for third-party submissions).", +) +@click.option( + "--source", + default=None, + help="Provenance label for this submission (max 512 chars, e.g. 'workflow:release-pipeline').", +) def form_submit( form_uuid: str, content: str | None, @@ -316,6 +335,9 @@ def form_submit( json_mode: bool, automation: bool, anonymous: bool, + guest_name: str | None, + guest_email: str | None, + source: str | None, ) -> None: """Submit a form response. @@ -332,7 +354,27 @@ def form_submit( dailybot form submit --content '{"":"Answer"}' --yes dailybot form submit --content '{"":"A"}' --automation dailybot form submit --content '{"":"A"}' --anonymous + dailybot form submit --content '{"":"A"}' --automation \\ + --guest-name "Release Bot" --guest-email "bot@example.com" \\ + --source "workflow:production-deploy" """ + if guest_email and not _EMAIL_RE.match(guest_email): + print_error(f"Invalid email format: {guest_email}") + raise SystemExit(1) + if source and len(source) > MAX_SUBMISSION_SOURCE_LENGTH: + print_error( + f"--source is too long ({len(source)} chars, max {MAX_SUBMISSION_SOURCE_LENGTH})." + ) + raise SystemExit(1) + + guest_user: dict[str, str] | None = None + if guest_name or guest_email: + guest_user = {} + if guest_name: + guest_user["full_name"] = guest_name + if guest_email: + guest_user["email"] = guest_email + client = require_auth() content_map = resolve_form_content(client, form_uuid, content) execute_form_submit( @@ -343,6 +385,8 @@ def form_submit( json_mode=json_mode, automation=automation, anonymous=anonymous, + guest_user=guest_user, + submission_source=source, ) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 962784a..8e06fe1 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -234,6 +234,10 @@ "search_query_too_long": ( "Search term is too long (maximum 256 characters). Shorten it and try again." ), + "guest_user_required": ( + "This form requires guest submitter info. " + "Add --guest-name and --guest-email to your command." + ), "invalid_date_range": ( "Invalid date. Use YYYY-MM-DD, and make sure the start date is on or before the end date." ), diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 28008f3..70f2e41 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -544,6 +544,8 @@ def execute_form_submit( json_mode: bool = False, automation: bool = False, anonymous: bool = False, + guest_user: dict[str, str] | None = None, + submission_source: str | None = None, ) -> None: """Submit a form response.""" resolved_name: str = form_name or form_uuid @@ -563,6 +565,13 @@ def execute_form_submit( summary_lines.append("Mode: automation (no submitter shown in channel)") if anonymous: summary_lines.append("Mode: anonymous (random name shown in channel)") + if guest_user: + guest_desc: str = guest_user.get("full_name", "") + if guest_user.get("email"): + guest_desc += f" <{guest_user['email']}>" if guest_desc else guest_user["email"] + summary_lines.append(f"Guest: {guest_desc}") + if submission_source: + summary_lines.append(f"Source: {submission_source}") summary_lines.append("Answers:") for question_uuid, answer in content_map.items(): summary_lines.append(f" {question_uuid}: {answer}") @@ -576,6 +585,8 @@ def execute_form_submit( content=content_map, automation=automation, anonymous=anonymous, + guest_user=guest_user, + submission_source=submission_source, ) except APIError as exc: exit_for_api_error(exc, json_mode) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index f7539c2..f9559a1 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -463,6 +463,31 @@ def test_submit_form_response_both_flags(self, client: DailyBotClient) -> None: } assert result["uuid"] == "response-uuid" + def test_submit_form_response_guest_user(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"uuid": "response-uuid", "is_guest_user": True} + + content: dict[str, str] = {"question-uuid": "Yes"} + guest: dict[str, str] = {"full_name": "Jane Doe", "email": "jane@example.com"} + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.submit_form_response( + "form-uuid", + content, + automation=True, + guest_user=guest, + submission_source="workflow:deploy", + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"] == { + "content": content, + "automation": True, + "guest_user": guest, + "submission_source": "workflow:deploy", + } + assert result["is_guest_user"] is True + def test_list_users_paginated(self, client: DailyBotClient) -> None: first_response: MagicMock = MagicMock(spec=httpx.Response) first_response.status_code = 200 diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 2ecc8ff..c91ff90 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -276,6 +276,8 @@ def test_form_submit_success( content={"question-uuid-1": "Yes"}, automation=False, anonymous=False, + guest_user=None, + submission_source=None, ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @@ -301,6 +303,8 @@ def test_form_submit_automation( content={"q": "A"}, automation=True, anonymous=False, + guest_user=None, + submission_source=None, ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @@ -326,8 +330,67 @@ def test_form_submit_anonymous( content={"q": "A"}, automation=False, anonymous=True, + guest_user=None, + submission_source=None, ) + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_submit_guest_and_source( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_forms.return_value = [{"id": "f-1", "name": "Feedback"}] + mock_client.submit_form_response.return_value = {"uuid": "r-1"} + + result = runner.invoke( + cli, + [ + "form", + "submit", + "f-1", + "--content", + '{"q":"A"}', + "--yes", + "--automation", + "--guest-name", + "Release Bot", + "--guest-email", + "bot@example.com", + "--source", + "workflow:deploy", + ], + ) + assert result.exit_code == 0 + mock_client.submit_form_response.assert_called_once_with( + form_uuid="f-1", + content={"q": "A"}, + automation=True, + anonymous=False, + guest_user={"full_name": "Release Bot", "email": "bot@example.com"}, + submission_source="workflow:deploy", + ) + + def test_form_submit_invalid_email(self, runner: CliRunner) -> None: + result = runner.invoke( + cli, + ["form", "submit", "f-1", "--content", '{"q":"A"}', "--guest-email", "not-an-email"], + ) + assert result.exit_code == 1 + assert "Invalid email" in result.output + + def test_form_submit_source_too_long(self, runner: CliRunner) -> None: + result = runner.invoke( + cli, + ["form", "submit", "f-1", "--content", '{"q":"A"}', "--source", "x" * 513], + ) + assert result.exit_code == 1 + assert "too long" in result.output + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_guided_prompts( @@ -363,6 +426,8 @@ def test_form_submit_guided_prompts( }, automation=False, anonymous=False, + guest_user=None, + submission_source=None, ) @patch("dailybot_cli.commands.user_scoped_actions._prompt_form_answer") @@ -407,6 +472,8 @@ def test_form_submit_guided_question_types( }, automation=False, anonymous=False, + guest_user=None, + submission_source=None, ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") From 49e438e33e446da7a1bc605a982c6093bf7cb333 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 12 Jul 2026 23:26:30 +0000 Subject: [PATCH 3/4] feat(form): add filtering, sorting, and source flags to form list and responses Forms list now supports --filter (all/me/public/approval/workflow/archived), --order (alphabetical/recent/total), --ascending, and --include-questions. Form responses now supports --source (member/anonymous/automation/public CSV), --submitter (UUID CSV, max 50), --flow-status (pending/approved/denied), --order (recent/oldest), and --ascending. Client-side validation rejects invalid source values and too many submitter IDs before hitting the API. Also optimizes dev-container bash prompt with a 5-second TTL cache on the git dirty check to avoid ~3s delays on macOS bind mounts. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 27 +++- dailybot_cli/commands/form.py | 130 ++++++++++++++++++- dailybot_cli/commands/public_api_helpers.py | 8 ++ dailybot_cli/commands/user_scoped_actions.py | 10 +- docker/custom_commands.sh | 24 +++- tests/api_client_test.py | 47 +++++++ tests/public_api_commands_test.py | 75 +++++++++++ 7 files changed, 309 insertions(+), 12 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index b506a63..ae63feb 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -809,6 +809,9 @@ def list_forms( include_questions: bool = False, include_archived: bool = False, owner: str | None = None, + filter_scope: str | None = None, + order: str | None = None, + is_ascend: bool = False, search: str | None = None, start_date: str | None = None, end_date: str | None = None, @@ -821,7 +824,8 @@ def list_forms( """GET /v1/forms/ — optionally expand questions, search, and page. The default response is org-scoped (every form the caller can see on the - webapp list view). Pass ``owner="me"`` to narrow to the caller's own forms. + webapp list view). Pass ``owner="me"`` to narrow to the caller's own forms, + or ``filter_scope`` to apply a server-side scope filter. When ``meta`` is given it is populated with ``count`` / ``next`` for a pagination footer. @@ -833,6 +837,12 @@ def list_forms( params["include_archived"] = "true" if owner: params["owner"] = owner + if filter_scope: + params["filter"] = filter_scope + if order: + params["order"] = order + if is_ascend: + params["is_ascend"] = "true" _merge_list_query(params, search=search, start_date=start_date, end_date=end_date) result: PaginatedResult = self._paginated_get( f"{self.api_url}/v1/forms/", @@ -889,6 +899,11 @@ def list_form_responses( state: str | None = None, all_responses: bool = False, user: str | None = None, + submission_sources: str | None = None, + submitter_user_ids: str | None = None, + flow_status: str | None = None, + order: str | None = None, + is_ascend: bool = False, date_from: str | None = None, date_to: str | None = None, search: str | None = None, @@ -914,6 +929,16 @@ def list_form_responses( params["all"] = "true" if user: params["user"] = user + if submission_sources: + params["submission_sources"] = submission_sources + if submitter_user_ids: + params["submitter_user_ids"] = submitter_user_ids + if flow_status: + params["flow_status"] = flow_status + if order: + params["order"] = order + if is_ascend: + params["is_ascend"] = "true" if date_from: params["date_from"] = date_from if date_to: diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 4069760..63e682e 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -63,6 +63,13 @@ MAX_SUBMISSION_SOURCE_LENGTH: int = 512 _EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +FORM_LIST_FILTERS: tuple[str, ...] = ("all", "me", "public", "approval", "workflow", "archived") +FORM_LIST_ORDERS: tuple[str, ...] = ("alphabetical", "recent", "total") +RESPONSE_ORDERS: tuple[str, ...] = ("recent", "oldest") +RESPONSE_SOURCES: tuple[str, ...] = ("member", "anonymous", "automation", "public") +RESPONSE_FLOW_STATUSES: tuple[str, ...] = ("pending", "approved", "denied") +MAX_SUBMITTER_IDS: int = 50 + _RESPONSES_ERROR_OVERRIDES: dict[str, str] = { "invalid_workflow_state": ( "This form has no workflow, so --state doesn't apply. Drop the flag, or run " @@ -197,11 +204,42 @@ def form() -> None: is_flag=True, help="Only forms you own (default lists every form you can see in the org).", ) +@click.option( + "--filter", + "filter_scope", + type=click.Choice(FORM_LIST_FILTERS, case_sensitive=False), + default=None, + help="Scope filter: all, me, public, approval, workflow, archived.", +) +@click.option( + "--order", + type=click.Choice(FORM_LIST_ORDERS, case_sensitive=False), + default=None, + help="Sort order: alphabetical, recent, or total responses.", +) +@click.option( + "--ascending", + "--asc", + "is_ascend", + is_flag=True, + default=False, + help="Sort ascending (default: descending).", +) +@click.option( + "--include-questions", + is_flag=True, + default=False, + help="Include question definitions in each form.", +) @query_options @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def form_list( include_archived: bool, mine: bool, + filter_scope: str | None, + order: str | None, + is_ascend: bool, + include_questions: bool, json_mode: bool, page: int | None, page_size: int | None, @@ -219,16 +257,19 @@ def form_list( \b Acts as you. You can only see and act on what you could in the webapp: by default this is every form in your org you have list-view access to. - Pass --mine to narrow to only the forms you own. - Archived forms are hidden unless you pass --include-archived. + Pass --mine to narrow to only the forms you own, or --filter to scope. + Archived forms are hidden unless you pass --include-archived or --filter archived. \b Examples: dailybot form list dailybot form list --mine + dailybot form list --filter workflow --order alphabetical --asc + dailybot form list --filter approval + dailybot form list --filter public --order total dailybot form list --search retro --since 2026-07-01 + dailybot form list --include-questions --json dailybot form list --all - dailybot form list --json """ enforce_plan_access("form_list", json_mode=json_mode) try: @@ -252,7 +293,11 @@ def form_list( client, json_mode=json_mode, include_archived=include_archived, + include_questions=include_questions, owner="me" if mine else None, + filter_scope=filter_scope, + order=order, + is_ascend=is_ascend, spec=spec, ) @@ -401,15 +446,48 @@ def form_submit( "--all", "all_responses", is_flag=True, - help="List everyone's responses (admin/owner only; a member gets 403).", + help="List everyone's responses (requires VIEW_REPORTS permission).", ) @click.option("--user", default=None, help="Filter to one user's responses (admin/owner only).") +@click.option( + "--source", + "submission_sources", + default=None, + help="Filter by origin (CSV: member, anonymous, automation, public).", +) +@click.option( + "--submitter", + "submitter_ids", + default=None, + help="Filter by submitter UUIDs (CSV, max 50).", +) +@click.option( + "--flow-status", + "flow_status", + type=click.Choice(RESPONSE_FLOW_STATUSES, case_sensitive=False), + default=None, + help="Approval flow status: pending, approved, denied.", +) +@click.option( + "--order", + type=click.Choice(RESPONSE_ORDERS, case_sensitive=False), + default=None, + help="Sort order: recent or oldest.", +) +@click.option( + "--ascending", + "--asc", + "is_ascend", + is_flag=True, + default=False, + help="Sort ascending (alias for --order oldest).", +) @click.option( "--from", "date_from", default=None, help="Responses on/after this date (YYYY-MM-DD)." ) @click.option("--to", "date_to", default=None, help="Responses on/before this date (YYYY-MM-DD).") @click.option( - "--search", "-s", "search", default=None, help="Filter response content (max 256 chars)." + "--search", "-s", "search", default=None, help="Search content and submitter (max 256 chars)." ) @click.option( "--latest", @@ -423,6 +501,11 @@ def form_responses( state: str | None, all_responses: bool, user: str | None, + submission_sources: str | None, + submitter_ids: str | None, + flow_status: str | None, + order: str | None, + is_ascend: bool, date_from: str | None, date_to: str | None, search: str | None, @@ -436,17 +519,45 @@ def form_responses( \b Acts as you. By default the server returns only responses you authored. - --all / --user surface others' responses and are admin/owner-only - (server-enforced — a member receives 403). --from / --to narrow the window. + --all / --user surface others' responses and require VIEW_REPORTS + permission (server-enforced — a member receives 403). + + \b + --source filters by origin: member, anonymous, automation, public. + Multiple values are comma-separated (OR semantics). + --submitter filters by specific user UUIDs (CSV, max 50, OR semantics). + --flow-status filters approval status: pending, approved, denied. \b Examples: dailybot form responses dailybot form responses --state qa --json dailybot form responses --all --from 2026-01-01 --to 2026-06-30 + dailybot form responses --all --source automation + dailybot form responses --all --source "member,automation" + dailybot form responses --all --flow-status pending + dailybot form responses --all --search "deploy" --order oldest dailybot form responses --user --json """ validate_user_filter(user) + if submission_sources: + parts: list[str] = [s.strip() for s in submission_sources.split(",")] + invalid: list[str] = [s for s in parts if s not in RESPONSE_SOURCES] + if invalid: + print_error( + f"Invalid source(s): {', '.join(invalid)}. Allowed: {', '.join(RESPONSE_SOURCES)}." + ) + raise SystemExit(1) + if submitter_ids: + ids: list[str] = [s.strip() for s in submitter_ids.split(",")] + if len(ids) > MAX_SUBMITTER_IDS: + print_error(f"Too many submitter UUIDs ({len(ids)}, max {MAX_SUBMITTER_IDS}).") + raise SystemExit(1) + + effective_order: str | None = order + if is_ascend and not order: + effective_order = "oldest" + client = require_auth() truncated_search: str | None = search[:256] if search is not None else None meta: dict[str, Any] = {} @@ -457,6 +568,11 @@ def form_responses( state=state, all_responses=all_responses, user=user, + submission_sources=submission_sources, + submitter_user_ids=submitter_ids, + flow_status=flow_status, + order=effective_order, + is_ascend=is_ascend, date_from=date_from, date_to=date_to, search=truncated_search, diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 8e06fe1..3d165e3 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -238,6 +238,14 @@ "This form requires guest submitter info. " "Add --guest-name and --guest-email to your command." ), + "invalid_filter": "Unknown --filter value. Use: all, me, public, approval, workflow, archived.", + "invalid_order": "Unknown --order value. Use: alphabetical, recent, total (forms) or recent, oldest (responses).", + "invalid_submission_sources": ( + "Unknown --source value. Use: member, anonymous, automation, public (comma-separated)." + ), + "invalid_submitter_user_id": "Invalid --submitter UUID format.", + "too_many_submitter_user_ids": "Too many --submitter UUIDs (max 50).", + "invalid_flow_status": "Unknown --flow-status value. Use: pending, approved, denied.", "invalid_date_range": ( "Invalid date. Use YYYY-MM-DD, and make sure the start date is on or before the end date." ), diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 70f2e41..03c76d4 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -500,19 +500,27 @@ def execute_form_list( *, json_mode: bool = False, include_archived: bool = False, + include_questions: bool = False, owner: str | None = None, + filter_scope: str | None = None, + order: str | None = None, + is_ascend: bool = False, spec: QuerySpec | None = None, ) -> list[dict[str, Any]] | None: """Fetch and display forms visible to the user (with question counts).""" query: QuerySpec = spec or QuerySpec() fetch_all: bool = resolve_fetch_all(query) meta: dict[str, Any] = {} + always_include_questions: bool = include_questions or not json_mode try: with console.status("Fetching forms..."): forms: list[dict[str, Any]] = client.list_forms( - include_questions=True, + include_questions=always_include_questions, include_archived=include_archived, owner=owner, + filter_scope=filter_scope, + order=order, + is_ascend=is_ascend, search=query.params.get("search"), start_date=query.params.get("start_date"), end_date=query.params.get("end_date"), diff --git a/docker/custom_commands.sh b/docker/custom_commands.sh index 581abb0..c144779 100644 --- a/docker/custom_commands.sh +++ b/docker/custom_commands.sh @@ -1053,6 +1053,13 @@ function git_status_indicator() { fi } +# Cached git dirty state for the prompt. `git status` costs ~0.4s on the +# macOS bind mount even with core.untrackedCache, so refresh at most every +# 5 seconds instead of on every prompt redraw. +__GIT_DIRTY_CACHE="" +__GIT_DIRTY_REPO="" +__GIT_DIRTY_TS=-10 + # Custom PS1 prompt with colors and git info function set_bash_prompt() { local exit_code=$? @@ -1067,7 +1074,9 @@ function set_bash_prompt() { # Get git branch and status local git_info="" - if git rev-parse --git-dir > /dev/null 2>&1; then + local repo_root + repo_root=$(git rev-parse --show-toplevel 2>/dev/null) + if [[ -n "$repo_root" ]]; then local branch branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) @@ -1075,8 +1084,17 @@ function set_bash_prompt() { branch='detached*' fi - # Check if there are uncommitted changes - if [[ -n $(git status --porcelain 2>/dev/null) ]]; then + if [[ "$repo_root" != "$__GIT_DIRTY_REPO" ]] || (( SECONDS - __GIT_DIRTY_TS >= 5 )); then + if [[ -n $(git status --porcelain 2>/dev/null) ]]; then + __GIT_DIRTY_CACHE=1 + else + __GIT_DIRTY_CACHE=0 + fi + __GIT_DIRTY_REPO="$repo_root" + __GIT_DIRTY_TS=$SECONDS + fi + + if [[ "$__GIT_DIRTY_CACHE" == 1 ]]; then git_info=" ${red}(${branch}*)${reset}" else git_info=" ${green}(${branch})${reset}" diff --git a/tests/api_client_test.py b/tests/api_client_test.py index f9559a1..92a9f59 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -488,6 +488,53 @@ def test_submit_form_response_guest_user(self, client: DailyBotClient) -> None: } assert result["is_guest_user"] is True + def test_list_forms_with_filter_and_order(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [{"uuid": "f-1", "name": "Form"}], + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_forms( + filter_scope="workflow", + order="alphabetical", + is_ascend=True, + ) + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"]["filter"] == "workflow" + assert call_kwargs["params"]["order"] == "alphabetical" + assert call_kwargs["params"]["is_ascend"] == "true" + + def test_list_form_responses_with_filters(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [{"uuid": "r-1"}], + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_form_responses( + "f-1", + all_responses=True, + submission_sources="member,automation", + flow_status="pending", + order="oldest", + ) + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"]["all"] == "true" + assert call_kwargs["params"]["submission_sources"] == "member,automation" + assert call_kwargs["params"]["flow_status"] == "pending" + assert call_kwargs["params"]["order"] == "oldest" + def test_list_users_paginated(self, client: DailyBotClient) -> None: first_response: MagicMock = MagicMock(spec=httpx.Response) first_response.status_code = 200 diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index c91ff90..a89ae33 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -246,6 +246,71 @@ def test_form_list_success( assert "Team feedback" in result.output assert "form-uuid-1" in result.output + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_list_with_filter_and_order( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_forms.return_value = [ + {"id": "f-1", "name": "Form", "is_active": True, "privacy": "everyone"} + ] + + result = runner.invoke( + cli, ["form", "list", "--filter", "workflow", "--order", "alphabetical", "--asc"] + ) + assert result.exit_code == 0 + mock_client.list_forms.assert_called_once() + call_kwargs: dict[str, Any] = mock_client.list_forms.call_args[1] + assert call_kwargs["filter_scope"] == "workflow" + assert call_kwargs["order"] == "alphabetical" + assert call_kwargs["is_ascend"] is True + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_responses_with_source_and_flow_status( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_form_responses.return_value = [] + + result = runner.invoke( + cli, + [ + "form", + "responses", + "f-1", + "--all", + "--source", + "member,automation", + "--flow-status", + "pending", + ], + ) + assert result.exit_code == 0 + call_kwargs: dict[str, Any] = mock_client.list_form_responses.call_args[1] + assert call_kwargs["all_responses"] is True + assert call_kwargs["submission_sources"] == "member,automation" + assert call_kwargs["flow_status"] == "pending" + + def test_form_responses_invalid_source(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["form", "responses", "f-1", "--source", "badvalue"]) + assert result.exit_code != 0 + + def test_form_responses_too_many_submitters(self, runner: CliRunner) -> None: + ids: str = ",".join(f"uuid-{i}" for i in range(51)) + result = runner.invoke(cli, ["form", "responses", "f-1", "--submitter", ids]) + assert result.exit_code == 1 + assert "Too many" in result.output + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") def test_form_submit_success( @@ -997,6 +1062,11 @@ def test_form_responses_latest( state=None, all_responses=False, user=None, + submission_sources=None, + submitter_user_ids=None, + flow_status=None, + order=None, + is_ascend=False, date_from=None, date_to=None, search=None, @@ -1026,6 +1096,11 @@ def test_form_responses_state_filter( state="qa", all_responses=False, user=None, + submission_sources=None, + submitter_user_ids=None, + flow_status=None, + order=None, + is_ascend=False, date_from=None, date_to=None, search=None, From c9aa6bc22ffa1a9c2e63a7cce9248d6a5a137d7d Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Sun, 12 Jul 2026 23:48:44 +0000 Subject: [PATCH 4/4] chore(skill): sync vendored forms skill with agent-skill v3.5.0 Updates the vendored dailybot forms skill documentation to match the full filtering, sorting, automation, guest identity, and source tracking capabilities shipped in this branch. Co-authored-by: Cursor --- .agents/skills/dailybot/forms/SKILL.md | 104 ++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 0798fec..b255af6 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -1,7 +1,7 @@ --- name: dailybot-forms description: List, inspect, submit, update, and transition form responses via Dailybot — including forms with workflow states and audience-scoped permissions. Also authors forms — create and configure a form (workflow states, permissions, anonymous/public/approval, ChatOps command) and manage its questions (types, report titles, variations, conditional logic). Use when the developer wants to see available forms, fill out a survey, continue an in-progress response, move a response between states, read prior responses, or create/configure a form. Do not use for daily check-ins — those go through dailybot-checkin. -version: "3.4.0" +version: "3.5.0" documentation_url: https://www.dailybot.com/skill.md user-invocable: true metadata: {"openclaw":{"emoji":"📋","homepage":"https://dailybot.com","requires":{"anyBins":["dailybot","curl"]},"primaryEnv":"DAILYBOT_API_KEY","install":[{"id":"cli-install-script","kind":"download","url":"https://cli.dailybot.com/install.sh","label":"Install Dailybot CLI (official script — preferred on Linux/macOS)"},{"id":"pip","kind":"pip","package":"dailybot-cli","bins":["dailybot"],"label":"Install Dailybot CLI via pip (fallback if binary fails)"}]}} @@ -172,6 +172,33 @@ wants their personal forms out of the full org list. dailybot form list --mine --json ``` +### Server-side filtering, sorting, and archived forms (CLI >= 3.5.0) + +| Flag | Values | Description | +|------|--------|-------------| +| `--filter` | `all`, `me`, `public`, `approval`, `workflow`, `archived` | Scope filter (server-side). | +| `--order` | `alphabetical`, `recent`, `total` | Sort field (`total` = total response count). | +| `--ascending` / `--asc` | flag | Sort ascending (default: descending). | +| `--include-questions` | flag | Include question definitions in each form. | +| `--include-archived` | flag | Include archived forms (hidden by default). | + +```bash +# Workflow-enabled forms, sorted alphabetically ascending: +dailybot form list --filter workflow --order alphabetical --asc --json + +# Only forms with approval flow: +dailybot form list --filter approval --json + +# Public forms sorted by total responses: +dailybot form list --filter public --order total --json + +# Archived forms: +dailybot form list --filter archived --json + +# Include question definitions: +dailybot form list --include-questions --json +``` + > **The envelope is unconditional.** `GET /v1/forms/` and the responses endpoint > below always return `{count, next, previous, results}`; no query parameter is > needed. See the shared doc. @@ -758,7 +785,8 @@ Useful flags: |------|-------------| | `--latest` | Return only the most recent response visible to the caller. | | `--state STATE` | Filter to responses in a specific workflow state. Pass the **state key** (`draft`), not the label (`Draft`) — read the keys from `workflow.states[].key` on `form get`. Filtering is server-side. On a form with no workflow the API returns `400 invalid_workflow_state`. | -| `--search TEXT` | (alias `--grep`) Case-insensitive substring filter across responses. Max 256 chars (truncated client-side). | +| `--all` | List everyone's responses (requires VIEW_REPORTS permission — admin/owner only; a member receives 403). | +| `--search TEXT` | Search response content and submitter name/email. Max 256 chars. Does **not** search anonymous member identities (privacy preserved). | | `--json` | Machine-readable output (stable shape). | > **`form responses` search.** The `--search` / `--grep` flag @@ -770,6 +798,48 @@ Useful flags: > responses (admin/owner only), not every page. > See [`../shared/list-query-and-errors.md`](../shared/list-query-and-errors.md). +### Advanced response filters (CLI >= 3.5.0) + +| Flag | Values | Description | +|------|--------|-------------| +| `--source` | CSV: `member`, `anonymous`, `automation`, `public` | Filter by submission origin (OR semantics within the group). | +| `--submitter` | CSV of UUIDs (max 50) | Filter by specific submitter user UUIDs. | +| `--flow-status` | `pending`, `approved`, `denied` | Approval flow status filter. Silently ignored if the form has no approval flow. | +| `--order` | `recent`, `oldest` | Sort by creation time. | +| `--ascending` / `--asc` | flag | Sort ascending (alias for `--order oldest`). | +| `--user UUID` | single UUID | Filter to one user's responses (admin/owner only). | +| `--from DATE` | YYYY-MM-DD | Responses on/after this date. | +| `--to DATE` | YYYY-MM-DD | Responses on/before this date. | + +**Submission sources** — every response belongs to exactly one bucket: + +| Source | Meaning | +|--------|---------| +| `member` | Identified org member submitted normally. | +| `anonymous` | Org member submitted anonymously (`collect_responses_anonymously=true`). | +| `automation` | Submitted via API/CLI with `--automation` (`is_dailybot_bot=true`). | +| `public` | Submitted via the public form URL by an external guest. | + +All filters compose with **AND** across groups and **OR** within each group: +`--source "member,automation" --flow-status pending --search "deploy"` → responses that are `(member OR automation) AND pending AND contain "deploy"`. + +```bash +# All automation submissions: +dailybot form responses --all --source automation --json + +# Multiple sources (OR): automation OR public: +dailybot form responses --all --source "automation,public" --json + +# Approval: only pending: +dailybot form responses --all --flow-status pending --json + +# Combined: member responses, approved, containing "sprint": +dailybot form responses --all --source member --flow-status approved --search "sprint" --json + +# Sorted oldest first in a date range: +dailybot form responses --all --order oldest --from 2026-07-01 --to 2026-07-10 --json +``` + ### JSON shape (single response) ```json @@ -971,11 +1041,37 @@ Always show the complete answer set before sending: | `--state` | `-s` | Optional initial state (workflow forms only). Defaults to the form's initial state. | | `--yes` | `-y` | Skip confirmation. | | `--json` | | Machine-readable JSON output. | -| `--automation` | | Submit as an automation — channel notifications show no submitter name. Use for web-form bridges, CI pipelines, or third-party integrations. (`CLI >= 3.4.0`) | -| `--anonymous` | | Submit anonymously — channel notifications show a random generated name (e.g. "Purple Elephant") instead of the real user. (`CLI >= 3.4.0`) | +| `--automation` | | Submit as an automation — channel notifications show no submitter name. (`CLI >= 3.4.0`) | +| `--anonymous` | | Submit anonymously — channel notifications show a random generated name. (`CLI >= 3.4.0`) | +| `--guest-name` | | Guest submitter full name (used with `--automation` for third-party submissions). (`CLI >= 3.5.0`) | +| `--guest-email` | | Guest submitter email (validated client-side). (`CLI >= 3.5.0`) | +| `--source` | | Provenance label, max 512 chars (e.g. `workflow:release-pipeline`). Works with any mode. (`CLI >= 3.5.0`) | > **`--automation` vs `--anonymous`:** Both are independent booleans. `--automation` hides the submitter entirely; `--anonymous` replaces them with a random name. When combined, `automation` takes precedence for the channel display. Neither flag affects who owns the response in the dashboard — it only controls the notification appearance. +### Submission modes (CLI >= 3.5.0) + +| Mode | Flags | Channel notification | +|------|-------|---------------------| +| Normal | (none) | Shows your name + avatar | +| Anonymous | `--anonymous` | Random generated name (e.g. "Purple Elephant") | +| Automation | `--automation` | No submitter shown | +| Automation + Guest | `--automation --guest-name "X" --guest-email "x@y"` | Guest name/email in body, no submitter avatar | +| Any + Source | `--source "label"` | Provenance label attached (works with any mode) | + +```bash +# Automation with guest identity and source tracking: +dailybot form submit \ + --content '{"":"done"}' --yes --automation \ + --guest-name "Release Bot" --guest-email "releases@example.com" \ + --source "workflow:production-deploy" +``` + +**Validation rules:** +- If the form has `require_email_and_name` enabled and you use `--automation`, both `--guest-name` and `--guest-email` are required (API returns `guest_user_required`). +- `--guest-email` is validated client-side for format. +- `--source` max length: 512 characters. + After submit, **re-read `current_state` and `allowed_transitions` on the returned payload** to decide the next step. ---