From 4349758ae530e8d5cc8dbb7b716d323f5d212b7f Mon Sep 17 00:00:00 2001 From: Sripathi Krishnan Date: Mon, 9 Mar 2026 15:19:45 +0530 Subject: [PATCH] feat: make --reason mandatory for all credentialed CLI commands Closes #32. Every command that requests credentials from the ExtraSuite server now requires --reason (-r / -m). Omitting it exits immediately with a clear error before any credential fetch or file I/O occurs. Changes: - _get_reason(): removed `default` param and EXTRASUITE_REASON env var fallback; exits 1 with an actionable message if reason is absent - All CLI modules: removed hardcoded default= strings from every call site - Moved _get_reason() to the top of handlers that previously did file I/O first (sheet batchUpdate, gmail compose/edit-draft/reply, calendar create/update, _cmd_share) so the error fires before any other work - Added -m as a third alias alongside existing -r / --reason - Updated --reason help text to say it is required - Help docs: updated README and all 32 credentialed command files to document --reason in their Flags/Options section and examples - Tests: 54 new tests covering _get_reason unit behaviour, all three parser aliases, every credentialed command, and offline commands Co-Authored-By: Claude Sonnet 4.6 --- client/src/extrasuite/client/cli/__init__.py | 7 +- client/src/extrasuite/client/cli/_common.py | 40 +- client/src/extrasuite/client/cli/calendar.py | 18 +- client/src/extrasuite/client/cli/contacts.py | 8 +- client/src/extrasuite/client/cli/doc.py | 4 +- client/src/extrasuite/client/cli/drive.py | 4 +- client/src/extrasuite/client/cli/form.py | 4 +- client/src/extrasuite/client/cli/gmail.py | 17 +- client/src/extrasuite/client/cli/script.py | 6 +- client/src/extrasuite/client/cli/sheet.py | 7 +- client/src/extrasuite/client/cli/slide.py | 4 +- client/src/extrasuite/client/help/README.md | 7 +- .../extrasuite/client/help/calendar/create.md | 2 + .../extrasuite/client/help/calendar/delete.md | 7 +- .../client/help/calendar/freebusy.md | 4 +- .../extrasuite/client/help/calendar/list.md | 7 +- .../extrasuite/client/help/calendar/rsvp.md | 4 +- .../extrasuite/client/help/calendar/search.md | 4 +- .../extrasuite/client/help/calendar/update.md | 2 + .../extrasuite/client/help/calendar/view.md | 4 +- .../extrasuite/client/help/contacts/README.md | 9 +- .../src/extrasuite/client/help/doc/create.md | 5 + client/src/extrasuite/client/help/doc/pull.md | 4 +- client/src/extrasuite/client/help/doc/push.md | 2 + client/src/extrasuite/client/help/drive/ls.md | 4 +- .../extrasuite/client/help/drive/search.md | 4 +- .../src/extrasuite/client/help/form/create.md | 5 + .../src/extrasuite/client/help/form/pull.md | 4 +- .../src/extrasuite/client/help/form/push.md | 2 + .../extrasuite/client/help/gmail/compose.md | 5 + .../client/help/gmail/edit-draft.md | 5 + .../src/extrasuite/client/help/gmail/list.md | 4 +- .../src/extrasuite/client/help/gmail/read.md | 4 +- .../src/extrasuite/client/help/gmail/reply.md | 7 +- .../extrasuite/client/help/script/create.md | 2 + .../src/extrasuite/client/help/script/pull.md | 4 +- .../src/extrasuite/client/help/script/push.md | 2 + .../client/help/sheet/batchupdate.md | 4 +- .../extrasuite/client/help/sheet/create.md | 5 + .../src/extrasuite/client/help/sheet/pull.md | 4 +- .../src/extrasuite/client/help/sheet/push.md | 2 + .../src/extrasuite/client/help/sheet/share.md | 2 + .../extrasuite/client/help/slide/create.md | 5 + .../src/extrasuite/client/help/slide/pull.md | 4 +- .../src/extrasuite/client/help/slide/push.md | 5 + client/tests/test_cli_help.py | 6 +- client/tests/test_reason_required.py | 534 ++++++++++++++++++ 47 files changed, 718 insertions(+), 85 deletions(-) create mode 100644 client/tests/test_reason_required.py diff --git a/client/src/extrasuite/client/cli/__init__.py b/client/src/extrasuite/client/cli/__init__.py index 8de002de..25b4030d 100644 --- a/client/src/extrasuite/client/cli/__init__.py +++ b/client/src/extrasuite/client/cli/__init__.py @@ -180,12 +180,13 @@ def build_parser() -> Any: auth_parent.add_argument( "--reason", "-r", + "-m", metavar="TEXT", default=None, help=( - "Why this operation is being performed. " - "Pass the user's actual intent for audit trails. " - "Can also be set via the EXTRASUITE_REASON environment variable." + "Why this operation is being performed (required). " + "Pass the user's actual intent so the server audit log records " + "the real reason credentials were requested." ), ) diff --git a/client/src/extrasuite/client/cli/_common.py b/client/src/extrasuite/client/cli/_common.py index 40c6e420..d5e207fe 100644 --- a/client/src/extrasuite/client/cli/_common.py +++ b/client/src/extrasuite/client/cli/_common.py @@ -205,18 +205,22 @@ def _auth_kwargs(args: Any) -> dict[str, Any]: return kwargs -def _get_reason(args: Any, *, default: str) -> str: - """Resolve the reason string for a token request. +def _get_reason(args: Any) -> str: + """Get the reason string from CLI args. - Precedence: --reason CLI flag > EXTRASUITE_REASON env var > hardcoded default. + Raises SystemExit with a clear message if --reason was not provided. + This is required for all credentialed commands so the server audit log + records the actual user intent. """ - import os - - return ( - getattr(args, "reason", None) - or os.environ.get("EXTRASUITE_REASON", "") - or default - ) + reason = getattr(args, "reason", None) + if not reason: + print( + "This command requires --reason.\n" + "Provide the user's actual intent so the server audit log records why credentials were requested.", + file=sys.stderr, + ) + sys.exit(1) + return reason def _get_credential(args: Any, *, command: dict[str, Any], reason: str) -> Credential: @@ -247,10 +251,13 @@ def _cmd_share(file_type: str, args: Any) -> None: from extrasuite.client.google_api import share_file from extrasuite.client.settings import load_trusted_contacts - # 1. Load trusted contacts (no user_domain injection — settings.toml is explicit) + # 1. Require reason before any other work + reason = _get_reason(args) + + # 2. Load trusted contacts (no user_domain injection — settings.toml is explicit) trusted = load_trusted_contacts() - # 2. Validate all emails before any API call + # 3. Validate all emails before any API call untrusted = [e for e in args.emails if not trusted.is_trusted(e)] if untrusted: for e in untrusted: @@ -261,11 +268,10 @@ def _cmd_share(file_type: str, args: Any) -> None: ) sys.exit(1) - # 3. Parse file ID using type-specific URL parser + # 4. Parse file ID using type-specific URL parser file_id = _URL_PARSERS[file_type](args.url) - # 4. Get DWD credential for drive.file.share - reason = _get_reason(args, default=f"Share {file_type} with users") + # 5. Get DWD credential for drive.file.share cred = _get_credential( args, command={ @@ -312,9 +318,7 @@ def _cmd_create(file_type: str, args: Any) -> None: manager = CredentialsManager(**_auth_kwargs(args)) # Get credential for drive.file.create — SA email is always in metadata - reason = _get_reason( - args, default=f"Create {file_type} and share with service account" - ) + reason = _get_reason(args) cred = manager.get_credential( command={ "type": "drive.file.create", diff --git a/client/src/extrasuite/client/cli/calendar.py b/client/src/extrasuite/client/cli/calendar.py index 4cc7bdbb..b6b2b2ea 100644 --- a/client/src/extrasuite/client/cli/calendar.py +++ b/client/src/extrasuite/client/cli/calendar.py @@ -18,7 +18,7 @@ def cmd_calendar_view(args: Any) -> None: ) time_min, time_max = parse_time_value(args.when) - reason = _get_reason(args, default="View calendar events") + reason = _get_reason(args) cred = _get_credential( args, command={ @@ -43,7 +43,7 @@ def cmd_calendar_list(args: Any) -> None: """List all calendars the user has access to.""" from extrasuite.client.google_api import format_calendars_markdown, list_calendars - reason = _get_reason(args, default="List calendars") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "calendar.list"}, @@ -76,7 +76,7 @@ def cmd_calendar_search(args: Any) -> None: time_max = time_min + timedelta(days=30) - reason = _get_reason(args, default="Search calendar events") + reason = _get_reason(args) cred = _get_credential( args, command={ @@ -115,7 +115,7 @@ def cmd_calendar_freebusy(args: Any) -> None: sys.exit(1) time_min, time_max = parse_time_value(args.when) - reason = _get_reason(args, default="Check free/busy") + reason = _get_reason(args) cred = _get_credential( args, command={ @@ -139,6 +139,7 @@ def cmd_calendar_create(args: Any) -> None: format_created_event_markdown, ) + reason = _get_reason(args) json_path = args.json if json_path == "-": event_json = _json.load(sys.stdin) @@ -159,8 +160,6 @@ def cmd_calendar_create(args: Any) -> None: end_time = (event_json.get("end") or {}).get("dateTime", "") or ( event_json.get("end") or {} ).get("date", "") - - reason = _get_reason(args, default="Create calendar event") cred = _get_credential( args, command={ @@ -191,6 +190,7 @@ def cmd_calendar_update(args: Any) -> None: update_calendar_event, ) + reason = _get_reason(args) json_path = args.json if json_path == "-": patch_json = _json.load(sys.stdin) @@ -204,8 +204,6 @@ def cmd_calendar_update(args: Any) -> None: attendees = [ a.get("email", "") for a in patch_json.get("attendees", []) if a.get("email") ] - - reason = _get_reason(args, default="Update calendar event") cred = _get_credential( args, command={ @@ -231,7 +229,7 @@ def cmd_calendar_delete(args: Any) -> None: """Delete (cancel) a calendar event.""" from extrasuite.client.google_api import delete_calendar_event - reason = _get_reason(args, default="Delete calendar event") + reason = _get_reason(args) cred = _get_credential( args, command={ @@ -266,7 +264,7 @@ def cmd_calendar_rsvp(args: Any) -> None: } api_response = response_map.get(args.response, args.response) - reason = _get_reason(args, default="RSVP to calendar event") + reason = _get_reason(args) cred = _get_credential( args, command={ diff --git a/client/src/extrasuite/client/cli/contacts.py b/client/src/extrasuite/client/cli/contacts.py index aaa3494d..f15d83b9 100644 --- a/client/src/extrasuite/client/cli/contacts.py +++ b/client/src/extrasuite/client/cli/contacts.py @@ -13,7 +13,7 @@ def cmd_contacts_sync(args: Any) -> None: """Sync Google Contacts to local DB.""" from extrasuite.client.contacts import sync - reason = _get_reason(args, default="Sync Google Contacts") + reason = _get_reason(args) token = _get_credential( args, command={"type": "contacts.read", "query": ""}, @@ -22,7 +22,7 @@ def cmd_contacts_sync(args: Any) -> None: other_token = _get_credential( args, command={"type": "contacts.other", "query": ""}, - reason=_get_reason(args, default="Sync Gmail-suggested contacts"), + reason=_get_reason(args), ) people_count, other_count = sync( token.token, other_token=other_token.token, verbose=True @@ -53,7 +53,7 @@ def cmd_contacts_search(args: Any) -> None: other_token_str = None if needs_sync: query_str = " ".join(queries) - reason = _get_reason(args, default="Sync Google Contacts") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "contacts.read", "query": query_str}, @@ -62,7 +62,7 @@ def cmd_contacts_search(args: Any) -> None: other_cred = _get_credential( args, command={"type": "contacts.other", "query": query_str}, - reason=_get_reason(args, default="Sync Gmail-suggested contacts"), + reason=_get_reason(args), ) token_str = cred.token other_token_str = other_cred.token diff --git a/client/src/extrasuite/client/cli/doc.py b/client/src/extrasuite/client/cli/doc.py index 27510250..33c07269 100644 --- a/client/src/extrasuite/client/cli/doc.py +++ b/client/src/extrasuite/client/cli/doc.py @@ -23,7 +23,7 @@ def cmd_doc_pull(args: Any) -> None: document_id = _parse_document_id(args.url) output_dir = Path(args.output_dir) if args.output_dir else Path() - reason = _get_reason(args, default="Pulling Google Doc") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "doc.pull", "file_url": args.url, "file_name": ""}, @@ -78,7 +78,7 @@ def cmd_doc_push(args: Any) -> None: """Push changes to a Google Doc.""" from extradoc import DocsClient, GoogleDocsTransport - reason = _get_reason(args, default="Pushing changes to Google Doc") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "doc.push", "file_url": "", "file_name": ""}, diff --git a/client/src/extrasuite/client/cli/drive.py b/client/src/extrasuite/client/cli/drive.py index d6be7164..a265ee9e 100644 --- a/client/src/extrasuite/client/cli/drive.py +++ b/client/src/extrasuite/client/cli/drive.py @@ -22,7 +22,7 @@ def cmd_drive_ls(args: Any) -> None: query_parts.append(f"'{folder_id}' in parents") query = " and ".join(query_parts) - reason = _get_reason(args, default="Listing Drive files") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "drive.ls", "folder_url": folder_url, "query": query}, @@ -44,7 +44,7 @@ def cmd_drive_search(args: Any) -> None: """Search files visible to the service account in Google Drive.""" from extrasuite.client.google_api import format_drive_files, list_drive_files - reason = _get_reason(args, default="Searching Drive files") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "drive.search", "query": args.query}, diff --git a/client/src/extrasuite/client/cli/form.py b/client/src/extrasuite/client/cli/form.py index 282a754a..afc77c33 100644 --- a/client/src/extrasuite/client/cli/form.py +++ b/client/src/extrasuite/client/cli/form.py @@ -23,7 +23,7 @@ def cmd_form_pull(args: Any) -> None: form_id = _parse_form_id(args.url) output_dir = Path(args.output_dir) if args.output_dir else Path() - reason = _get_reason(args, default="Pulling Google Form") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "form.pull", "file_url": args.url, "file_name": ""}, @@ -64,7 +64,7 @@ def cmd_form_push(args: Any) -> None: """Push changes to a Google Form.""" from extraform import FormsClient, GoogleFormsTransport - reason = _get_reason(args, default="Pushing changes to Google Form") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "form.push", "file_url": "", "file_name": ""}, diff --git a/client/src/extrasuite/client/cli/gmail.py b/client/src/extrasuite/client/cli/gmail.py index 03b17496..cb45644e 100644 --- a/client/src/extrasuite/client/cli/gmail.py +++ b/client/src/extrasuite/client/cli/gmail.py @@ -68,12 +68,11 @@ def cmd_gmail_compose(args: Any) -> None: """Save an email draft from a markdown file with front matter.""" from extrasuite.client.google_api import create_gmail_draft + reason = _get_reason(args) attach = getattr(args, "attach", None) to, subject, body, cc, bcc, attachments = _parse_email_file_args( Path(args.file), cli_attachments=attach ) - - reason = _get_reason(args, default="Save email draft") cred = _get_credential( args, command={ @@ -102,12 +101,11 @@ def cmd_gmail_edit_draft(args: Any) -> None: """Update an existing Gmail draft from a markdown file with front matter.""" from extrasuite.client.google_api import update_gmail_draft + reason = _get_reason(args) attach = getattr(args, "attach", None) to, subject, body, cc, bcc, attachments = _parse_email_file_args( Path(args.file), cli_attachments=attach ) - - reason = _get_reason(args, default="Edit email draft") cred = _get_credential( args, command={ @@ -141,6 +139,9 @@ def cmd_gmail_reply(args: Any) -> None: parse_email_file, ) + # gmail.reply maps to [gmail.readonly + gmail.compose] on the server — + # a single request returns a token valid for both scopes. + reason = _get_reason(args) file_path = Path(args.file) if not file_path.exists(): print(f"Error: File not found: {file_path}", file=sys.stderr) @@ -158,10 +159,6 @@ def cmd_gmail_reply(args: Any) -> None: print(f"Error: Attachment not found: {p}", file=sys.stderr) sys.exit(1) attachments.append(p) - - # gmail.reply maps to [gmail.readonly + gmail.compose] on the server — - # a single request returns a token valid for both scopes. - reason = _get_reason(args, default="Save reply draft") cred = _get_credential( args, command={ @@ -229,7 +226,7 @@ def cmd_gmail_list(args: Any) -> None: ) query = getattr(args, "query", "") or "" - reason = _get_reason(args, default="List Gmail threads") + reason = _get_reason(args) cred = _get_credential( args, command={ @@ -286,7 +283,7 @@ def cmd_gmail_read(args: Any) -> None: get_thread, ) - reason = _get_reason(args, default="Read Gmail thread") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "gmail.read", "thread_id": args.thread_id}, diff --git a/client/src/extrasuite/client/cli/script.py b/client/src/extrasuite/client/cli/script.py index 835e4cc0..e3dbb0a8 100644 --- a/client/src/extrasuite/client/cli/script.py +++ b/client/src/extrasuite/client/cli/script.py @@ -17,7 +17,7 @@ def cmd_script_pull(args: Any) -> None: script_id = parse_script_id(args.url) output_dir = Path(args.output_dir) if args.output_dir else Path() - reason = _get_reason(args, default="Pull Apps Script project") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "script.pull", "file_url": args.url, "file_name": ""}, @@ -61,7 +61,7 @@ def cmd_script_push(args: Any) -> None: """Push changes to a Google Apps Script project.""" from extrascript import GoogleAppsScriptTransport, ScriptClient - reason = _get_reason(args, default="Push Apps Script project") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "script.push", "file_url": "", "file_name": ""}, @@ -99,7 +99,7 @@ def cmd_script_create(args: Any) -> None: from extrascript import GoogleAppsScriptTransport, ScriptClient from extrascript.client import parse_file_id - reason = _get_reason(args, default="Create Apps Script project") + reason = _get_reason(args) bind_to = args.bind_to or "" cred = _get_credential( args, diff --git a/client/src/extrasuite/client/cli/sheet.py b/client/src/extrasuite/client/cli/sheet.py index 040861ce..a399398b 100644 --- a/client/src/extrasuite/client/cli/sheet.py +++ b/client/src/extrasuite/client/cli/sheet.py @@ -24,7 +24,7 @@ def cmd_sheet_pull(args: Any) -> None: spreadsheet_id = _parse_spreadsheet_id(args.url) output_dir = Path(args.output_dir) if args.output_dir else Path() - reason = _get_reason(args, default="Pulling Google Sheet") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "sheet.pull", "file_url": args.url, "file_name": ""}, @@ -96,7 +96,7 @@ def cmd_sheet_push(args: Any) -> None: from extrasheet import GoogleSheetsTransport, SheetsClient - reason = _get_reason(args, default="Pushing changes to Google Sheet") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "sheet.push", "file_url": "", "file_name": ""}, @@ -125,6 +125,7 @@ def cmd_sheet_batchupdate(args: Any) -> None: from extrasheet import GoogleSheetsTransport + reason = _get_reason(args) spreadsheet_id = _parse_spreadsheet_id(args.url) requests_path = Path(args.requests_file) if not requests_path.exists(): @@ -141,8 +142,6 @@ def cmd_sheet_batchupdate(args: Any) -> None: "Error: Expected a list of requests or {requests: [...]}", file=sys.stderr ) sys.exit(1) - - reason = _get_reason(args, default="Executing batchUpdate on Google Sheet") cred = _get_credential( args, command={ diff --git a/client/src/extrasuite/client/cli/slide.py b/client/src/extrasuite/client/cli/slide.py index 4fd677c2..a60db6c0 100644 --- a/client/src/extrasuite/client/cli/slide.py +++ b/client/src/extrasuite/client/cli/slide.py @@ -22,7 +22,7 @@ def cmd_slide_pull(args: Any) -> None: presentation_id = _parse_presentation_id(args.url) output_dir = Path(args.output_dir) if args.output_dir else Path() - reason = _get_reason(args, default="Pulling Google Slides") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "slide.pull", "file_url": args.url, "file_name": ""}, @@ -67,7 +67,7 @@ def cmd_slide_push(args: Any) -> None: """Push changes to a Google Slides presentation.""" from extraslide import GoogleSlidesTransport, SlidesClient - reason = _get_reason(args, default="Pushing changes to Google Slides") + reason = _get_reason(args) cred = _get_credential( args, command={"type": "slide.push", "file_url": "", "file_name": ""}, diff --git a/client/src/extrasuite/client/help/README.md b/client/src/extrasuite/client/help/README.md index 1638eea9..240301aa 100644 --- a/client/src/extrasuite/client/help/README.md +++ b/client/src/extrasuite/client/help/README.md @@ -14,9 +14,12 @@ ExtraSuite - edit Google Workspace files with AI agents using a local pull-edit- ## Core Workflow (sheet, slide, doc, form, script) - extrasuite pull [output_dir] # Convert google workspace file to local files inside / + extrasuite pull --reason "state the user's intent that led to this command" # Edit files inside / - extrasuite push # Identify changes made and apply them to the google workspace file + extrasuite push --reason "state the user's intent that led to this command" + +--reason (also -r or -m) is required on every command that contacts the server. +Omitting it exits immediately with an error. Make all changes locally and push once when done. Always re-pull before making further changes. diff --git a/client/src/extrasuite/client/help/calendar/create.md b/client/src/extrasuite/client/help/calendar/create.md index c13053c3..c3d37869 100644 --- a/client/src/extrasuite/client/help/calendar/create.md +++ b/client/src/extrasuite/client/help/calendar/create.md @@ -7,6 +7,8 @@ Create a Google Calendar event from a JSON file. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --json PATH Path to event JSON file, or - to read from stdin --calendar ID Calendar ID (default: primary, or value from JSON) diff --git a/client/src/extrasuite/client/help/calendar/delete.md b/client/src/extrasuite/client/help/calendar/delete.md index fe2ffe49..405212c6 100644 --- a/client/src/extrasuite/client/help/calendar/delete.md +++ b/client/src/extrasuite/client/help/calendar/delete.md @@ -2,6 +2,11 @@ Cancel (delete) a Google Calendar event. By default, cancellation notification emails are sent to all attendees. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite calendar delete EVENT_ID [--calendar ID] [--no-notify] [--this-and-following] @@ -17,7 +22,7 @@ By default, cancellation notification emails are sent to all attendees. ## Examples # Cancel a one-off event - extrasuite calendar delete abc123xyz + extrasuite calendar delete abc123xyz --reason "state the user's intent that led to this command" # Cancel silently (no notification emails) extrasuite calendar delete abc123xyz --no-notify diff --git a/client/src/extrasuite/client/help/calendar/freebusy.md b/client/src/extrasuite/client/help/calendar/freebusy.md index a95a22b6..f1c098ab 100644 --- a/client/src/extrasuite/client/help/calendar/freebusy.md +++ b/client/src/extrasuite/client/help/calendar/freebusy.md @@ -9,6 +9,8 @@ access to view the other person's actual events. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --attendees EMAIL One or more email addresses (space-separated) --when RANGE Time range to check (default: next-week) @@ -28,7 +30,7 @@ and excluded from the common free slots calculation. ## Examples # Find when Alice and Bob are both free next week - extrasuite calendar freebusy --attendees alice@example.com bob@example.com + extrasuite calendar freebusy --attendees alice@example.com bob@example.com --reason "state the user's intent that led to this command" # Check a larger group for this week extrasuite calendar freebusy --attendees a@co.com b@co.com c@co.com --when this-week diff --git a/client/src/extrasuite/client/help/calendar/list.md b/client/src/extrasuite/client/help/calendar/list.md index bfaa7341..2062ccc8 100644 --- a/client/src/extrasuite/client/help/calendar/list.md +++ b/client/src/extrasuite/client/help/calendar/list.md @@ -1,8 +1,13 @@ List all Google Calendars you have access to. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage - extrasuite calendar list + extrasuite calendar list --reason "state the user's intent that led to this command" ## Output diff --git a/client/src/extrasuite/client/help/calendar/rsvp.md b/client/src/extrasuite/client/help/calendar/rsvp.md index 4fc074c5..cb93c4a4 100644 --- a/client/src/extrasuite/client/help/calendar/rsvp.md +++ b/client/src/extrasuite/client/help/calendar/rsvp.md @@ -6,6 +6,8 @@ Accept, decline, or mark tentative for a calendar event you've been invited to. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + EVENT_ID Event ID (from `calendar view` or `calendar search`) --response Your response: accept, decline, or tentative (required) --comment TEXT Optional message to include with your response @@ -20,7 +22,7 @@ Accept, decline, or mark tentative for a calendar event you've been invited to. ## Examples # Accept an invite - extrasuite calendar rsvp abc123xyz --response accept + extrasuite calendar rsvp abc123xyz --response accept --reason "state the user's intent that led to this command" # Decline with a reason extrasuite calendar rsvp abc123xyz --response decline --comment "I have a conflict - will send a delegate" diff --git a/client/src/extrasuite/client/help/calendar/search.md b/client/src/extrasuite/client/help/calendar/search.md index 9c020aca..4e7dc70f 100644 --- a/client/src/extrasuite/client/help/calendar/search.md +++ b/client/src/extrasuite/client/help/calendar/search.md @@ -8,6 +8,8 @@ Search Google Calendar events by title, description, or attendee email. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --query TEXT Search text matched against title and description --attendee EMAIL Filter to events that include this attendee's email --from DATE Start of search range (default: today) @@ -26,7 +28,7 @@ attendees, and conferencing links. ## Examples # Find all meetings mentioning "Acme" - extrasuite calendar search --query "Acme" + extrasuite calendar search --query "Acme" --reason "state the user's intent that led to this command" # Find all meetings with a specific colleague extrasuite calendar search --attendee alice@example.com diff --git a/client/src/extrasuite/client/help/calendar/update.md b/client/src/extrasuite/client/help/calendar/update.md index 9d83f31f..07bd328e 100644 --- a/client/src/extrasuite/client/help/calendar/update.md +++ b/client/src/extrasuite/client/help/calendar/update.md @@ -9,6 +9,8 @@ Only fields present in the JSON are changed. Omitted fields are left as-is. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + EVENT_ID Event ID (from `calendar view` or `calendar search` output) --json PATH Path to patch JSON file, or - to read from stdin --calendar ID Calendar ID (default: primary) diff --git a/client/src/extrasuite/client/help/calendar/view.md b/client/src/extrasuite/client/help/calendar/view.md index ce6a23d4..2174c0e6 100644 --- a/client/src/extrasuite/client/help/calendar/view.md +++ b/client/src/extrasuite/client/help/calendar/view.md @@ -6,6 +6,8 @@ View Google Calendar events for a time range. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --when Time range to view (default: today) --calendar Calendar ID (default: primary) @@ -30,7 +32,7 @@ Events grouped by date. Each event shows: ## Examples - extrasuite calendar view + extrasuite calendar view --reason "state the user's intent that led to this command" extrasuite calendar view --when tomorrow extrasuite calendar view --when this-week extrasuite calendar view --when 2025-03-15 diff --git a/client/src/extrasuite/client/help/contacts/README.md b/client/src/extrasuite/client/help/contacts/README.md index 7dfc76cb..81bb1a54 100644 --- a/client/src/extrasuite/client/help/contacts/README.md +++ b/client/src/extrasuite/client/help/contacts/README.md @@ -8,10 +8,10 @@ The local DB is synced from Google Contacts and Gmail-suggested contacts. ## Workflow # Search by name or company (auto-syncs if DB is missing or stale) - extrasuite contacts search "Alice Example" "Bob Corp" + extrasuite contacts search "Alice Example" "Bob Corp" --reason "state the user's intent that led to this command" # Explicitly sync the contacts DB - extrasuite contacts sync + extrasuite contacts sync --reason "state the user's intent that led to this command" ## Commands @@ -20,14 +20,15 @@ The local DB is synced from Google Contacts and Gmail-suggested contacts. ## contacts search - extrasuite contacts search [ ...] + extrasuite contacts search [ ...] --reason TEXT Each query is matched independently. Returns a JSON array of matching contacts with name, email, and organization. The DB is auto-synced if it is missing or stale. +--reason is required when a sync is triggered. ## contacts sync - extrasuite contacts sync + extrasuite contacts sync --reason TEXT Pulls your Google Contacts and Gmail-suggested contacts into a local SQLite DB. Run this explicitly if you need fresh data right now. diff --git a/client/src/extrasuite/client/help/doc/create.md b/client/src/extrasuite/client/help/doc/create.md index a70f6e07..ee7100ff 100644 --- a/client/src/extrasuite/client/help/doc/create.md +++ b/client/src/extrasuite/client/help/doc/create.md @@ -1,5 +1,10 @@ Create a new Google Doc and share it with your service account. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite doc create diff --git a/client/src/extrasuite/client/help/doc/pull.md b/client/src/extrasuite/client/help/doc/pull.md index 7547d7f9..c3ff2538 100644 --- a/client/src/extrasuite/client/help/doc/pull.md +++ b/client/src/extrasuite/client/help/doc/pull.md @@ -11,6 +11,8 @@ Download a Google Doc to a local folder. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --no-raw Skip saving raw API responses (.raw/ folder) ## Output @@ -56,5 +58,5 @@ comments cannot be added via the API. ## Example - extrasuite doc pull https://docs.google.com/document/d/abc123 + extrasuite doc pull https://docs.google.com/document/d/abc123 --reason "state the user's intent that led to this command" extrasuite doc pull https://docs.google.com/document/d/abc123 /tmp/docs diff --git a/client/src/extrasuite/client/help/doc/push.md b/client/src/extrasuite/client/help/doc/push.md index 69cac820..3fc1f38b 100644 --- a/client/src/extrasuite/client/help/doc/push.md +++ b/client/src/extrasuite/client/help/doc/push.md @@ -10,6 +10,8 @@ Apply local XML changes to Google Docs. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + -f, --force Push despite validation warnings --verify Re-pull after push to verify changes were applied correctly diff --git a/client/src/extrasuite/client/help/drive/ls.md b/client/src/extrasuite/client/help/drive/ls.md index 95878dc5..c677f728 100644 --- a/client/src/extrasuite/client/help/drive/ls.md +++ b/client/src/extrasuite/client/help/drive/ls.md @@ -6,6 +6,8 @@ List files in Google Drive that are visible to the service account. ## Options + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --folder URL Limit listing to files inside this folder (URL or folder ID) --max N Maximum number of files to return (default: 20) --page TOKEN Page token for pagination (printed at the end of previous output) @@ -16,7 +18,7 @@ Prints a table with columns: NAME, TYPE, MODIFIED, URL. ## Examples - extrasuite drive ls + extrasuite drive ls --reason "state the user's intent that led to this command" extrasuite drive ls --folder https://drive.google.com/drive/folders/FOLDER_ID extrasuite drive ls --max 50 diff --git a/client/src/extrasuite/client/help/drive/search.md b/client/src/extrasuite/client/help/drive/search.md index 287aee19..0a625de3 100644 --- a/client/src/extrasuite/client/help/drive/search.md +++ b/client/src/extrasuite/client/help/drive/search.md @@ -10,6 +10,8 @@ Search Google Drive files visible to the service account using a query string. ## Options + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --max N Maximum number of files to return (default: 20) --page TOKEN Page token for pagination (printed at the end of previous output) @@ -26,7 +28,7 @@ Prints a table with columns: NAME, TYPE, MODIFIED, URL. ## Examples - extrasuite drive search "name contains 'budget'" + extrasuite drive search "name contains 'budget'" --reason "state the user's intent that led to this command" extrasuite drive search "mimeType = 'application/vnd.google-apps.spreadsheet'" ## Notes diff --git a/client/src/extrasuite/client/help/form/create.md b/client/src/extrasuite/client/help/form/create.md index 895e8611..b9379f7b 100644 --- a/client/src/extrasuite/client/help/form/create.md +++ b/client/src/extrasuite/client/help/form/create.md @@ -1,5 +1,10 @@ Create a new Google Form and share it with your service account. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite form create <title> diff --git a/client/src/extrasuite/client/help/form/pull.md b/client/src/extrasuite/client/help/form/pull.md index b2a197a9..1dd56b01 100644 --- a/client/src/extrasuite/client/help/form/pull.md +++ b/client/src/extrasuite/client/help/form/pull.md @@ -11,6 +11,8 @@ Download a Google Form to a local folder. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --responses Include form responses in the output --max-responses N Max responses to fetch (default: 100) --no-raw Skip saving raw API responses (.raw/ folder) @@ -70,5 +72,5 @@ After push, form.json is updated with API-assigned IDs — no need to re-pull. ## Example - extrasuite form pull https://docs.google.com/forms/d/abc123 + extrasuite form pull https://docs.google.com/forms/d/abc123 --reason "state the user's intent that led to this command" extrasuite form pull https://docs.google.com/forms/d/abc123 --responses diff --git a/client/src/extrasuite/client/help/form/push.md b/client/src/extrasuite/client/help/form/push.md index 4a0a0e2e..b5228a64 100644 --- a/client/src/extrasuite/client/help/form/push.md +++ b/client/src/extrasuite/client/help/form/push.md @@ -10,6 +10,8 @@ Apply local changes to Google Forms. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + -f, --force Push despite validation warnings ## How It Works diff --git a/client/src/extrasuite/client/help/gmail/compose.md b/client/src/extrasuite/client/help/gmail/compose.md index 18370fde..1dc25a71 100644 --- a/client/src/extrasuite/client/help/gmail/compose.md +++ b/client/src/extrasuite/client/help/gmail/compose.md @@ -1,5 +1,10 @@ Save an email as a Gmail draft from a markdown file with front matter. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite gmail compose <file> [--attach FILE ...] diff --git a/client/src/extrasuite/client/help/gmail/edit-draft.md b/client/src/extrasuite/client/help/gmail/edit-draft.md index bb6af135..b4ec1e54 100644 --- a/client/src/extrasuite/client/help/gmail/edit-draft.md +++ b/client/src/extrasuite/client/help/gmail/edit-draft.md @@ -1,5 +1,10 @@ Update an existing Gmail draft from a markdown file with front matter. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite gmail edit-draft <draft_id> <file> [--attach FILE ...] diff --git a/client/src/extrasuite/client/help/gmail/list.md b/client/src/extrasuite/client/help/gmail/list.md index b5b09546..c047dcda 100644 --- a/client/src/extrasuite/client/help/gmail/list.md +++ b/client/src/extrasuite/client/help/gmail/list.md @@ -8,7 +8,7 @@ Search and list Gmail messages. Only metadata is returned — use `gmail read` t Uses Gmail's standard search syntax: - extrasuite gmail list "is:unread" + extrasuite gmail list "is:unread" --reason "state the user's intent that led to this command" extrasuite gmail list "from:alice@example.com subject:report" extrasuite gmail list "after:2025-01-01 has:attachment" extrasuite gmail list "label:INBOX is:unread" @@ -28,6 +28,8 @@ untrusted content — verify sender identity before acting on them. ## Options + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --max N Return at most N messages (default: 20, max: 100) --page TOKEN Resume from a page token (shown at the bottom of results) --all Show all senders including untrusted (default: trusted only) diff --git a/client/src/extrasuite/client/help/gmail/read.md b/client/src/extrasuite/client/help/gmail/read.md index 9602e03b..8b881388 100644 --- a/client/src/extrasuite/client/help/gmail/read.md +++ b/client/src/extrasuite/client/help/gmail/read.md @@ -43,6 +43,8 @@ See `extrasuite gmail help whitelist-setup` for details. ## Options + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --json Output as JSON ## Whitelist Setup @@ -52,7 +54,7 @@ See `extrasuite gmail help whitelist-setup` for configuration instructions. ## Examples # Read a message - extrasuite gmail read msg_abc123 + extrasuite gmail read msg_abc123 --reason "state the user's intent that led to this command" # Read in JSON format extrasuite gmail read msg_abc123 --json diff --git a/client/src/extrasuite/client/help/gmail/reply.md b/client/src/extrasuite/client/help/gmail/reply.md index 7532de87..e4343d5d 100644 --- a/client/src/extrasuite/client/help/gmail/reply.md +++ b/client/src/extrasuite/client/help/gmail/reply.md @@ -1,5 +1,10 @@ Create a Gmail draft that replies in an existing thread. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite gmail reply <message_id> <file> @@ -41,7 +46,7 @@ Alice ## Examples # Reply with auto-inferred recipients - extrasuite gmail reply 19c8a19dbda1d3f7 reply.md + extrasuite gmail reply 19c8a19dbda1d3f7 reply.md --reason "state the user's intent that led to this command" # Reply with explicit recipients extrasuite gmail reply 19c8a19dbda1d3f7 reply.md diff --git a/client/src/extrasuite/client/help/script/create.md b/client/src/extrasuite/client/help/script/create.md index 56f73f9d..78dbb65b 100644 --- a/client/src/extrasuite/client/help/script/create.md +++ b/client/src/extrasuite/client/help/script/create.md @@ -11,6 +11,8 @@ Create a new Google Apps Script project. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --bind-to <url> Bind to an existing Google Drive file (Sheet, Doc, etc.) ## Output diff --git a/client/src/extrasuite/client/help/script/pull.md b/client/src/extrasuite/client/help/script/pull.md index dec87fe1..23390515 100644 --- a/client/src/extrasuite/client/help/script/pull.md +++ b/client/src/extrasuite/client/help/script/pull.md @@ -11,6 +11,8 @@ Download a Google Apps Script project to a local folder. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --no-raw Skip saving raw API responses (.raw/ folder) ## Output @@ -25,4 +27,4 @@ Creates <output_dir>/<script_id>/ with: ## Example - extrasuite script pull https://script.google.com/d/abc123/edit + extrasuite script pull https://script.google.com/d/abc123/edit --reason "state the user's intent that led to this command" diff --git a/client/src/extrasuite/client/help/script/push.md b/client/src/extrasuite/client/help/script/push.md index f7279da2..5545e205 100644 --- a/client/src/extrasuite/client/help/script/push.md +++ b/client/src/extrasuite/client/help/script/push.md @@ -10,6 +10,8 @@ Apply local changes to a Google Apps Script project. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --skip-lint Skip JavaScript linting before push ## How It Works diff --git a/client/src/extrasuite/client/help/sheet/batchupdate.md b/client/src/extrasuite/client/help/sheet/batchupdate.md index 4d176bc0..ce152f77 100644 --- a/client/src/extrasuite/client/help/sheet/batchupdate.md +++ b/client/src/extrasuite/client/help/sheet/batchupdate.md @@ -14,6 +14,8 @@ pull-edit-push workflow cannot express. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + -v, --verbose Print the full API response ## Operations Only Possible via batchUpdate @@ -39,7 +41,7 @@ Or with a wrapper object: Always re-pull - the local state is now stale: - extrasuite sheet batchUpdate <url> requests.json + extrasuite sheet batchUpdate <url> requests.json --reason "state the user's intent that led to this command" extrasuite sheet pull <url> ## Examples diff --git a/client/src/extrasuite/client/help/sheet/create.md b/client/src/extrasuite/client/help/sheet/create.md index f5651a8f..c0ddabd2 100644 --- a/client/src/extrasuite/client/help/sheet/create.md +++ b/client/src/extrasuite/client/help/sheet/create.md @@ -1,5 +1,10 @@ Create a new Google Spreadsheet and share it with your service account. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite sheet create <title> diff --git a/client/src/extrasuite/client/help/sheet/pull.md b/client/src/extrasuite/client/help/sheet/pull.md index 0d9fcff3..07d40219 100644 --- a/client/src/extrasuite/client/help/sheet/pull.md +++ b/client/src/extrasuite/client/help/sheet/pull.md @@ -11,6 +11,8 @@ Download a Google Sheet to a local folder. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --max-rows N Max rows per sheet to download (default: 100) --no-limit Download all rows --no-raw Skip saving raw API responses (.raw/ folder) @@ -61,5 +63,5 @@ Creates <output_dir>/<spreadsheet_id>/ with: ## Example - extrasuite sheet pull https://docs.google.com/spreadsheets/d/abc123 + extrasuite sheet pull https://docs.google.com/spreadsheets/d/abc123 --reason "state the user's intent that led to this command" extrasuite sheet pull https://docs.google.com/spreadsheets/d/abc123 /tmp/sheets diff --git a/client/src/extrasuite/client/help/sheet/push.md b/client/src/extrasuite/client/help/sheet/push.md index 6f822f2f..495a385f 100644 --- a/client/src/extrasuite/client/help/sheet/push.md +++ b/client/src/extrasuite/client/help/sheet/push.md @@ -10,6 +10,8 @@ Apply local changes to Google Sheets. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + -f, --force Push despite validation warnings ## How It Works diff --git a/client/src/extrasuite/client/help/sheet/share.md b/client/src/extrasuite/client/help/sheet/share.md index a128b021..5a89faa2 100644 --- a/client/src/extrasuite/client/help/sheet/share.md +++ b/client/src/extrasuite/client/help/sheet/share.md @@ -14,6 +14,8 @@ Share a Google Workspace file with one or more trusted contacts. ## Options + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --role Permission role: reader, writer, or commenter (default: reader) ## Trusted Contacts diff --git a/client/src/extrasuite/client/help/slide/create.md b/client/src/extrasuite/client/help/slide/create.md index fabe6a8f..1b2f6fd3 100644 --- a/client/src/extrasuite/client/help/slide/create.md +++ b/client/src/extrasuite/client/help/slide/create.md @@ -1,5 +1,10 @@ Create a new Google Slides presentation and share it with your service account. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite slide create <title> diff --git a/client/src/extrasuite/client/help/slide/pull.md b/client/src/extrasuite/client/help/slide/pull.md index 3af67eab..7f37437a 100644 --- a/client/src/extrasuite/client/help/slide/pull.md +++ b/client/src/extrasuite/client/help/slide/pull.md @@ -11,6 +11,8 @@ Download a Google Slides presentation to a local folder. ## Flags + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + --no-raw Skip saving raw API responses (.raw/ folder) ## Output @@ -47,5 +49,5 @@ For SML syntax, see: extrasuite slide help sml-reference ## Example - extrasuite slide pull https://docs.google.com/presentation/d/abc123 + extrasuite slide pull https://docs.google.com/presentation/d/abc123 --reason "state the user's intent that led to this command" extrasuite slide pull https://docs.google.com/presentation/d/abc123 /tmp/slides diff --git a/client/src/extrasuite/client/help/slide/push.md b/client/src/extrasuite/client/help/slide/push.md index 4e0bb707..0e3fc8f1 100644 --- a/client/src/extrasuite/client/help/slide/push.md +++ b/client/src/extrasuite/client/help/slide/push.md @@ -1,5 +1,10 @@ Apply local SML changes to Google Slides. + +## Flags + + --reason TEXT State the user's intent that led to this command (required). Also -r or -m. + ## Usage extrasuite slide push <folder> diff --git a/client/tests/test_cli_help.py b/client/tests/test_cli_help.py index dbcf8e16..56d47a7a 100644 --- a/client/tests/test_cli_help.py +++ b/client/tests/test_cli_help.py @@ -3,12 +3,14 @@ from __future__ import annotations from argparse import Namespace - -import pytest +from typing import TYPE_CHECKING from extrasuite.client.cli import build_parser from extrasuite.client.cli._common import cmd_module_help +if TYPE_CHECKING: + import pytest + def test_sheet_help_lists_formulas_topic(capsys: pytest.CaptureFixture[str]) -> None: args = Namespace(command="sheet", topic_parts=[]) diff --git a/client/tests/test_reason_required.py b/client/tests/test_reason_required.py new file mode 100644 index 00000000..57b0b73e --- /dev/null +++ b/client/tests/test_reason_required.py @@ -0,0 +1,534 @@ +"""Tests for mandatory --reason on credentialed CLI commands (issue #32). + +Covers: +- _get_reason() exits with code 1 and a clear stderr message when reason is absent +- _get_reason() returns the reason string when present +- EXTRASUITE_REASON env var is no longer accepted as a fallback +- --reason / -r / -m all set args.reason +- Every credentialed command exits early (before credential fetch) when --reason is absent +- Offline commands (diff, lint) do NOT require --reason +""" + +from __future__ import annotations + +import contextlib +from argparse import Namespace +from typing import Any +from unittest.mock import patch + +import pytest + +from extrasuite.client.cli import build_parser +from extrasuite.client.cli._common import _get_reason +from extrasuite.client.cli.calendar import ( + cmd_calendar_create, + cmd_calendar_delete, + cmd_calendar_freebusy, + cmd_calendar_list, + cmd_calendar_rsvp, + cmd_calendar_search, + cmd_calendar_update, + cmd_calendar_view, +) +from extrasuite.client.cli.contacts import cmd_contacts_search, cmd_contacts_sync +from extrasuite.client.cli.doc import ( + cmd_doc_create, + cmd_doc_diff, + cmd_doc_pull, + cmd_doc_push, + cmd_doc_share, +) +from extrasuite.client.cli.drive import cmd_drive_ls, cmd_drive_search +from extrasuite.client.cli.form import ( + cmd_form_create, + cmd_form_diff, + cmd_form_pull, + cmd_form_push, + cmd_form_share, +) +from extrasuite.client.cli.gmail import ( + cmd_gmail_compose, + cmd_gmail_edit_draft, + cmd_gmail_list, + cmd_gmail_read, + cmd_gmail_reply, +) +from extrasuite.client.cli.script import ( + cmd_script_create, + cmd_script_diff, + cmd_script_lint, + cmd_script_pull, + cmd_script_push, + cmd_script_share, +) +from extrasuite.client.cli.sheet import ( + cmd_sheet_batchupdate, + cmd_sheet_create, + cmd_sheet_diff, + cmd_sheet_pull, + cmd_sheet_push, + cmd_sheet_share, +) +from extrasuite.client.cli.slide import ( + cmd_slide_create, + cmd_slide_diff, + cmd_slide_pull, + cmd_slide_push, + cmd_slide_share, +) + +# --------------------------------------------------------------------------- +# _get_reason() unit tests +# --------------------------------------------------------------------------- + + +def test_get_reason_returns_reason_when_provided() -> None: + args = Namespace(reason="user wants the Q4 budget sheet") + assert _get_reason(args) == "user wants the Q4 budget sheet" + + +def test_get_reason_exits_when_reason_is_none(capsys: pytest.CaptureFixture[str]) -> None: + args = Namespace(reason=None) + with pytest.raises(SystemExit) as exc_info: + _get_reason(args) + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "--reason" in err + assert "audit log" in err + + +def test_get_reason_exits_when_reason_is_empty_string() -> None: + args = Namespace(reason="") + with pytest.raises(SystemExit) as exc_info: + _get_reason(args) + assert exc_info.value.code == 1 + + +def test_get_reason_exits_when_reason_attribute_missing() -> None: + """Args object with no 'reason' attribute at all should also fail.""" + args = Namespace() # no reason attribute + with pytest.raises(SystemExit) as exc_info: + _get_reason(args) + assert exc_info.value.code == 1 + + +def test_get_reason_error_goes_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + args = Namespace(reason=None) + with pytest.raises(SystemExit): + _get_reason(args) + out, err = capsys.readouterr() + assert out == "" # nothing on stdout + assert "This command requires --reason" in err + + +def test_extrasuite_reason_env_var_is_not_a_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setting EXTRASUITE_REASON must NOT bypass the --reason requirement.""" + monkeypatch.setenv("EXTRASUITE_REASON", "some env reason") + args = Namespace(reason=None) + with pytest.raises(SystemExit) as exc_info: + _get_reason(args) + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# Parser alias tests +# --------------------------------------------------------------------------- + + +def test_reason_long_flag() -> None: + parser = build_parser() + args = parser.parse_args(["sheet", "pull", "https://docs.google.com/spreadsheets/d/abc", "--reason", "user wants data"]) + assert args.reason == "user wants data" + + +def test_reason_short_flag_r() -> None: + parser = build_parser() + args = parser.parse_args(["sheet", "pull", "https://docs.google.com/spreadsheets/d/abc", "-r", "user wants data"]) + assert args.reason == "user wants data" + + +def test_reason_short_flag_m() -> None: + parser = build_parser() + args = parser.parse_args(["sheet", "pull", "https://docs.google.com/spreadsheets/d/abc", "-m", "user wants data"]) + assert args.reason == "user wants data" + + +def test_reason_default_is_none() -> None: + """Without any flag, reason should be None (so _get_reason fails fast).""" + parser = build_parser() + args = parser.parse_args(["sheet", "pull", "https://docs.google.com/spreadsheets/d/abc"]) + assert args.reason is None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SA_PATH = "/tmp/fake-sa.json" + +_SHEET_URL = "https://docs.google.com/spreadsheets/d/abc" +_SLIDE_URL = "https://docs.google.com/presentation/d/abc" +_FORM_URL = "https://docs.google.com/forms/d/abc" +_DOC_URL = "https://docs.google.com/document/d/abc" +_SCRIPT_URL = "https://script.google.com/d/abc" + + +def _parse(*argv: str) -> Any: + return build_parser().parse_args(list(argv)) + + +def _assert_exits_without_reason( + handler: Any, args: Any, capsys: pytest.CaptureFixture[str] +) -> None: + """Assert that a command handler exits with code 1 and the --reason message.""" + with pytest.raises(SystemExit) as exc_info: + handler(args) + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "--reason" in err + + +def _assert_no_reason_error( + handler: Any, args: Any, capsys: pytest.CaptureFixture[str] +) -> None: + """Run handler and confirm it did NOT fail with the --reason message.""" + with contextlib.suppress(SystemExit, Exception): + handler(args) + err = capsys.readouterr().err + assert "This command requires --reason" not in err + + +# --------------------------------------------------------------------------- +# Credentialed commands: all must fail without --reason +# --------------------------------------------------------------------------- +# We pass --service-account to bypass any browser/keyring flow — the command +# should still die at _get_reason() before reaching any credential logic. + + +class TestCredentialedCommandsRequireReason: + """Each credentialed command must exit 1 with a clear message when --reason is absent.""" + + def test_sheet_pull(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_sheet_pull, + _parse("sheet", "pull", _SHEET_URL, "--service-account", _SA_PATH), + capsys, + ) + + def test_sheet_push(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_sheet_push, + _parse("sheet", "push", "/tmp/folder", "--service-account", _SA_PATH), + capsys, + ) + + def test_sheet_batchupdate(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_sheet_batchupdate, + _parse("sheet", "batchUpdate", _SHEET_URL, "/tmp/requests.json", "--service-account", _SA_PATH), + capsys, + ) + + def test_sheet_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_sheet_create, + _parse("sheet", "create", "My Sheet", "--service-account", _SA_PATH), + capsys, + ) + + def test_sheet_share(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_sheet_share, + _parse("sheet", "share", _SHEET_URL, "bob@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_slide_pull(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_slide_pull, + _parse("slide", "pull", _SLIDE_URL, "--service-account", _SA_PATH), + capsys, + ) + + def test_slide_push(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_slide_push, + _parse("slide", "push", "/tmp/folder", "--service-account", _SA_PATH), + capsys, + ) + + def test_slide_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_slide_create, + _parse("slide", "create", "My Deck", "--service-account", _SA_PATH), + capsys, + ) + + def test_slide_share(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_slide_share, + _parse("slide", "share", _SLIDE_URL, "bob@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_form_pull(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_form_pull, + _parse("form", "pull", _FORM_URL, "--service-account", _SA_PATH), + capsys, + ) + + def test_form_push(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_form_push, + _parse("form", "push", "/tmp/folder", "--service-account", _SA_PATH), + capsys, + ) + + def test_form_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_form_create, + _parse("form", "create", "My Form", "--service-account", _SA_PATH), + capsys, + ) + + def test_form_share(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_form_share, + _parse("form", "share", _FORM_URL, "bob@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_doc_pull(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_doc_pull, + _parse("doc", "pull", _DOC_URL, "--service-account", _SA_PATH), + capsys, + ) + + def test_doc_push(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_doc_push, + _parse("doc", "push", "/tmp/folder", "--service-account", _SA_PATH), + capsys, + ) + + def test_doc_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_doc_create, + _parse("doc", "create", "My Doc", "--service-account", _SA_PATH), + capsys, + ) + + def test_doc_share(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_doc_share, + _parse("doc", "share", _DOC_URL, "bob@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_script_pull(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_script_pull, + _parse("script", "pull", _SCRIPT_URL, "--service-account", _SA_PATH), + capsys, + ) + + def test_script_push(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_script_push, + _parse("script", "push", "/tmp/folder", "--service-account", _SA_PATH), + capsys, + ) + + def test_script_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_script_create, + _parse("script", "create", "My Script", "--service-account", _SA_PATH), + capsys, + ) + + def test_script_share(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_script_share, + _parse("script", "share", _SCRIPT_URL, "bob@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_gmail_compose(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_gmail_compose, + _parse("gmail", "compose", "/tmp/email.md", "--service-account", _SA_PATH), + capsys, + ) + + def test_gmail_edit_draft(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_gmail_edit_draft, + _parse("gmail", "edit-draft", "draft123", "/tmp/email.md", "--service-account", _SA_PATH), + capsys, + ) + + def test_gmail_reply(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_gmail_reply, + _parse("gmail", "reply", "thread123", "/tmp/reply.md", "--service-account", _SA_PATH), + capsys, + ) + + def test_gmail_list(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_gmail_list, + _parse("gmail", "list", "--service-account", _SA_PATH), + capsys, + ) + + def test_gmail_read(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_gmail_read, + _parse("gmail", "read", "thread123", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_view(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_view, + _parse("calendar", "view", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_list(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_list, + _parse("calendar", "list", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_search(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_search, + _parse("calendar", "search", "--query", "standup", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_freebusy(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_freebusy, + _parse("calendar", "freebusy", "--attendees", "alice@example.com", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_create(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_create, + _parse("calendar", "create", "--json", "/tmp/event.json", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_update(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_update, + _parse("calendar", "update", "evt123", "--json", "/tmp/patch.json", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_delete(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_delete, + _parse("calendar", "delete", "evt123", "--service-account", _SA_PATH), + capsys, + ) + + def test_calendar_rsvp(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_calendar_rsvp, + _parse("calendar", "rsvp", "evt123", "--response", "accept", "--service-account", _SA_PATH), + capsys, + ) + + def test_contacts_sync(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_contacts_sync, + _parse("contacts", "sync", "--service-account", _SA_PATH), + capsys, + ) + + def test_contacts_search(self, capsys: pytest.CaptureFixture[str]) -> None: + # contacts search only hits _get_reason when a sync is needed; + # force the sync path by pretending the DB doesn't exist. + # _DB_PATH is imported locally inside cmd_contacts_search, so patch at source. + with patch("extrasuite.client.contacts._DB_PATH") as mock_db_path: + mock_db_path.exists.return_value = False + _assert_exits_without_reason( + cmd_contacts_search, + _parse("contacts", "search", "alice", "--service-account", _SA_PATH), + capsys, + ) + + def test_drive_ls(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_drive_ls, + _parse("drive", "ls", "--service-account", _SA_PATH), + capsys, + ) + + def test_drive_search(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_exits_without_reason( + cmd_drive_search, + _parse("drive", "search", "budget", "--service-account", _SA_PATH), + capsys, + ) + + +# --------------------------------------------------------------------------- +# Offline commands must NOT require --reason +# --------------------------------------------------------------------------- +# These commands do no credential fetching; they should not exit due to a +# missing --reason. They may fail for other reasons (bad folder path, etc.) +# but NOT with the "This command requires --reason" message. + + +class TestOfflineCommandsDoNotRequireReason: + def test_sheet_diff(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_sheet_diff, + _parse("sheet", "diff", "/tmp/nonexistent"), + capsys, + ) + + def test_slide_diff(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_slide_diff, + _parse("slide", "diff", "/tmp/nonexistent"), + capsys, + ) + + def test_form_diff(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_form_diff, + _parse("form", "diff", "/tmp/nonexistent"), + capsys, + ) + + def test_doc_diff(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_doc_diff, + _parse("doc", "diff", "/tmp/nonexistent"), + capsys, + ) + + def test_script_diff(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_script_diff, + _parse("script", "diff", "/tmp/nonexistent"), + capsys, + ) + + def test_script_lint(self, capsys: pytest.CaptureFixture[str]) -> None: + _assert_no_reason_error( + cmd_script_lint, + _parse("script", "lint", "/tmp/nonexistent"), + capsys, + )