109 Commits

Author SHA1 Message Date
Justin Poehnelt 6f92e5b5f6 fix: stderr/output hygiene rollup (#525)
* fix: stderr/output hygiene rollup — diagnostics to stderr, colored labels, auth propagation

Component 1 (PR #485): Route triage 'no messages' and modelarmor error
bodies to stderr so stdout stays machine-readable.

Component 2 (PR #466): Add colored error[variant]: labels to stderr
on TTY, respecting NO_COLOR. Replace emoji hint with colorized text.

Component 3 (PR #446): Propagate auth errors as GwsError::Auth in
calendar, chat, docs, drive, script, sheets helpers instead of
silently proceeding unauthenticated. dry-run bypass preserved.

* fix: deduplicate accessNotConfigured stderr output

Use if/else so that accessNotConfigured errors get the specialized
hint guidance instead of redundantly printing both the generic summary
and the hint. Non-accessNotConfigured Api errors and all other variants
still get the generic error[variant]: summary line.

* test: remove misleading model_armor_post error format test

model_armor_post function. A proper integration test would require
HTTP mocking (e.g. mockito/wiremock) which is out of scope for this PR.

* refactor: deduplicate error printing else branches

Use early return in accessNotConfigured branch so the generic
eprintln! only appears once, eliminating the duplicated else blocks.

* security: sanitize error messages before printing to stderr

Add sanitize_for_terminal() to strip control characters (ANSI escape
sequences, bell, backspace, etc.) from error messages before printing
to stderr, preventing terminal escape injection from API responses.
Newlines and tabs are preserved for readability.

The function is pub(crate) so it can be reused by other modules that
print untrusted content to stderr.

* fix: sanitize all stderr error output across codebase

Apply sanitize_for_terminal() to all 16 remaining eprintln sites
that print unsanitized error strings to stderr. This prevents
terminal escape sequence injection through error messages.

Files updated:
- workflows.rs (4 sites)
- watch.rs (2 sites)
- gmail/mod.rs (3 sites)
- executor.rs (1 site)
- subscribe.rs (1 site)
- token_storage.rs (2 sites)
- credential_store.rs (2 sites)
- setup.rs (1 site)
- generate_skills.rs (1 site)

Also fixes clippy: map_err -> inspect_err where closure only logs.

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-17 14:15:11 -06:00
Justin Poehnelt b241a5be92 fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths (#523)
* fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths

- Cap Retry-After header at 60s to prevent hostile servers from hanging the CLI
- Extract compute_retry_delay() with saturating_pow for safe exponential backoff
- Sanitize mimeType by stripping control characters to prevent MIME header injection
- Add validate_safe_file_path() for --upload and --output path validation
- Gate --upload/--output through path validation in main.rs before any I/O

Consolidates security fixes from PRs #448 and #447.

* chore: regenerate skills [skip ci]

* fix: resolve mimeType sanitization bypass and document TOCTOU caveat

- Restructure resolve_upload_mime() using or_else chain so all code paths
  go through control-char stripping (early returns were bypassing it)
- Document TOCTOU limitation in validate_safe_file_path() as known caveat

* fix: use canonicalized paths for I/O and normalize .. in non-existent suffix

Address review comments:
- main.rs: use canonicalized path from validate_safe_file_path for I/O
  instead of discarding it (closes TOCTOU gap)
- validate.rs: add normalize_dotdot() to resolve .. components in
  non-existent suffix (prevents traversal via doesnt_exist/../../etc/passwd)
- Add regression test for non-existent prefix traversal bypass

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-17 13:18:06 -06:00
github-actions[bot] 157257035b style: cargo fmt 2026-03-17 18:07:08 +00:00
Abhi Ram Reddy Salammagari 8458104c83 fix(validate): reject dangerous Unicode characters in input validation (#484)
* fix(validate): reject dangerous Unicode characters in input validation

Extend reject_control_chars() and validate_resource_name() to reject
zero-width chars (U+200B, U+200C, U+200D, U+FEFF), bidi overrides
(U+202A-U+202E), Unicode line/paragraph separators (U+2028, U+2029),
and directional isolates (U+2066-U+2069). These multi-byte codepoints
were silently passing the previous ASCII-range byte check, creating
a potential injection vector when the CLI is driven by LLM agents.
Adds 20 new tests covering all rejected categories plus documented
intentional pass-throughs (homoglyphs, overlong names).

* refactor(validate): replace slice const with is_rejected_unicode() fn using matches!

Switch from a REJECTED_UNICODE_CHARS &[char] constant + .contains() (O(M)
linear scan per character) to an is_rejected_unicode(c: char) -> bool helper
that uses the matches! macro with char ranges. This gives O(1) per character
and reads more clearly at call sites via .any(is_rejected_unicode).

* perf(validate): combine ASCII and Unicode checks into a single pass

Address review feedback: replace the two-iteration approach (one byte
scan + one char scan) in reject_control_chars with a single char loop,
and merge the separate is_control / is_rejected_unicode guards in
validate_resource_name into one any() call. Avoids iterating the input
string twice, closing the O(N*M) concern raised by the reviewer.
2026-03-17 12:06:44 -06:00
github-actions[bot] 186ee3051e style: cargo fmt 2026-03-17 18:05:16 +00:00
Sidharth Rajmohan 1b0a21fa13 feat: support google meet video conferencing in calendar +insert (#506)
* feat: support google meet video conferencing in calendar +insert (#461, #419)

* feat: add unit tests for google meet and align dependencies

* chore: add changeset for google meet support

* style: cargo fmt

* chore: address PR feedback - restore ratatui 0.30.0 and clarify help text

* test: use robust assertions for google meet insert

* feat: make Google Meet requestId deterministic for idempotency

* fix: restore dependencies and make Google Meet requestId seed more robust

* fix: use JSON serialization for robust Google Meet requestId seed

* fix: improve error handling for Google Meet requestId seed serialization

* fix: ensure idempotency key seed structure matches request body

* fix: align seed_payload attendees structure with actual request body
2026-03-17 12:05:00 -06:00
Sidharth Rajmohan 811fe7baeb fix(security): eliminate TOCTOU race condition in atomic writes (#500)
- Use tempfile::NamedTempFile for synchronous atomic_write to ensure 0600 permissions at creation.
- Use tokio::fs::OpenOptions with mode(0o600) for asynchronous atomic_write_async.
- Remove redundant set_permissions calls in oauth_config.rs and credential_store.rs.
- Ensure tempfile is a regular dependency (was dev-dependency).
- Add tests to verify file permissions on Unix systems.

Fixes #401
2026-03-17 11:45:18 -06:00
Justin Poehnelt c61b9cbd40 fix(gmail): RFC 2047 encode non-ASCII display names in address headers (#482)
Add encode_address_header() that parses mailbox lists, RFC 2047
encodes only the display-name portion of non-ASCII addresses, and
leaves email addresses untouched. Applied to all 4 address headers
(To, From, Cc, Bcc) in MessageBuilder::build().

Previously, only Subject got RFC 2047 encoding while address headers
only got CRLF sanitization, causing mojibake for non-ASCII names.

Supersedes #405, #458, #469. Closes #404.

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-13 17:21:41 -06:00
Justin Poehnelt 47afe5fdb3 feat(timezone): use Google account timezone for day-boundary calculations (#480)
Replace machine-local chrono::Local and UTC epoch math with the
authenticated user's Google account timezone (Calendar Settings API).

- Add chrono-tz dependency for IANA timezone parsing
- New src/timezone.rs: resolve timezone with priority:
  --timezone flag > 24h cache > Calendar API > local fallback
- calendar.rs: add --timezone/--tz flag to +agenda
- workflows.rs: fix +standup-report, +weekly-digest, +meeting-prep
- auth_commands.rs: invalidate timezone cache on logout
- Update README.md and AGENTS.md with timezone docs

Supersedes #369 and #462.

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-13 16:41:16 -06:00
Justin Poehnelt 6f3e0906d3 feat: add opt-in structured HTTP request logging via tracing (#478)
Add PII-free structured logging controlled by two environment variables:
- GOOGLE_WORKSPACE_CLI_LOG: stderr log filter (e.g., gws=debug)
- GOOGLE_WORKSPACE_CLI_LOG_FILE: directory for JSON log files (daily rotation)

Logging is silent by default (zero overhead). Instrumented sites:
- executor.rs: API request/response (method, status, latency)
- client.rs: 429 retry events
- discovery.rs: cache hit vs network fetch

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-13 15:27:38 -06:00
Justin Poehnelt 945ac91604 fix: stream multipart uploads to avoid OOM on large files (#477)
* fix: stream multipart uploads to avoid OOM on large files

Replace buffered file read + build_multipart_body in build_http_request
with streaming build_multipart_stream using tokio_util::io::ReaderStream.

Memory usage drops from O(file_size) to O(64 KB) regardless of upload size.
Content-Length is pre-computed from file metadata so Google APIs still
receive the correct header without buffering.

Fixes #244

* refactor: improve error messages per review feedback

- Metadata error now says 'Failed to get metadata' instead of misleading
  'Failed to read upload file'
- File::open error in stream now includes the file path for easier debugging

* test: add Drive upload smoketest to CI

Uploads a small text file, verifies the response has a file ID,
then cleans up by deleting it. Validates the streaming multipart
upload path end-to-end against real Google APIs.

* fix(ci): use drive +upload helper for upload smoketest

The upload is via the +upload helper command, not files create --upload.
Also pipe stderr through tee so errors are visible in CI logs.

* revert: remove Drive upload smoketest (insufficient CI scopes)

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-13 14:56:33 -06:00
github-actions[bot] 835e1f16c0 style: cargo fmt 2026-03-13 20:18:49 +00:00
Huglo dc561e0ffe feat: add --upload-content-type flag and smart MIME inference for uploads (#429)
The multipart upload media Content-Type is now resolved independently
from the metadata mimeType, enabling Drive import conversions (e.g.
Markdown → Google Docs) to work automatically.

Priority order for the media MIME type:
1. --upload-content-type flag (explicit override)
2. File extension inference (best guess for what the bytes are)
3. Metadata mimeType (backward-compat fallback)
4. application/octet-stream

Previously the metadata mimeType was reused for the media part, which
meant uploading `notes.md` with mimeType set to
`application/vnd.google-apps.document` would incorrectly label the
bytes as a Google Doc instead of text/markdown.

Made-with: Cursor
2026-03-13 14:18:30 -06:00
github-actions[bot] fc873dcc00 style: cargo fmt 2026-03-13 19:53:28 +00:00
Anshul Garg bb94016dd8 fix(security): validate space name in chat +send (#444)
Add validate_resource_name() to the --space argument in chat +send,
consistent with the validation already applied in gmail +watch,
events +subscribe, and modelarmor helpers. Prevents path traversal
and query injection via the space parameter.
2026-03-13 13:53:15 -06:00
github-actions[bot] 25ccbd7812 style: cargo fmt 2026-03-13 19:35:30 +00:00
Anshul Garg 957b9991f8 test(gmail): add unit tests for +triage argument parsing (#445)
triage.rs was the only helper file with zero test coverage.
Add tests for: default max (20), explicit max, non-numeric max
fallback, custom query, labels flag, and output format selection.
2026-03-13 13:35:08 -06:00
Anshul Garg 44767ed8ee fix(auth): map People service to contacts/directory scope prefixes (#414)
The People API exposes scopes like `contacts`, `contacts.readonly`,
and `directory.readonly`, none of which start with `people`. When
users ran `gws auth login -s people`, zero scopes matched because
`map_service_to_scope_prefix` returned `"people"` verbatim.

Change `map_service_to_scope_prefix` to `map_service_to_scope_prefixes`
returning a Vec to support services that map to multiple scope
prefixes. Add the `people` → `["contacts", "directory"]` mapping.

Chat scopes (chat.spaces, chat.messages) already matched correctly
since they share the `chat` prefix, but this is now verified by tests.

Closes #310
Closes #316
2026-03-13 13:20:56 -06:00
Anshul Garg 8ef27a262c fix(calendar): use local timezone for agenda day boundaries (#443)
* fix(calendar): use local timezone for agenda day boundaries

Previously, --today and --tomorrow computed day boundaries using UTC
epoch arithmetic, so after local midnight \!= UTC midnight the wrong
day's events were returned. Now uses chrono::Local to derive midnight
in the user's timezone.

Also fixes --today which had no explicit branch and fell through to
the generic "N days from now" path.

* fix: use earliest() for DST-safe local time resolution

Replace .single().unwrap_or(local_now) with .earliest().unwrap_or(local_now)
to correctly handle DST transitions where midnight may be ambiguous
or non-existent. Applied in both production code and test.
2026-03-13 13:14:34 -06:00
Anshul Garg 21b18407b9 fix(schema): expose repeated field and expand array query params (#415)
* fix(schema): expose repeated field and expand array query params

Two related fixes for repeated/array query parameters:

1. `gws schema` now includes `"repeated": true` in parameter output
   when the Discovery Document marks a parameter as repeated. This
   lets users know which params accept multiple values.

2. When `--params` contains a JSON array for a parameter marked
   `repeated: true`, the executor now expands it into multiple
   query parameters (e.g. `?h=Subject&h=Date&h=From`) instead of
   stringifying the array as a single value.

The query_params type changes from HashMap<String, String> to
Vec<(String, String)> to support multiple entries with the same key.

Closes #300

* fix: warn when array is passed for a non-repeated query parameter

Address review feedback: print a warning to stderr when a JSON array
is provided for a parameter not marked as repeated, since the array
will be stringified rather than expanded. Directs users to gws schema
to check which parameters accept arrays.

* fix: import MethodParameter in executor test module

The test `test_build_url_repeated_query_param_expands_array` uses
MethodParameter but it was not imported in the test module's use
statement, causing a compilation error in CI.

* fix: pass all query params in a single request.query() call

Consolidate query parameters (including pageToken) into a single
request.query() call to ensure repeated parameters are sent correctly
instead of being overwritten by successive calls.
2026-03-13 13:13:59 -06:00
github-actions[bot] 659c66b5c2 style: cargo fmt 2026-03-13 19:13:25 +00:00
Anshul Garg 4d7b420d53 fix(sheets): preserve multi-row structure in +append --json-values (#410)
* fix(sheets): preserve multi-row structure in +append --json-values

Previously, `parse_append_args` called `.flatten()` on the parsed
`Vec<Vec<String>>`, collapsing all rows into a single flat vector.
Combined with `build_append_request` wrapping the flat vec in another
array, this meant `[["Alice","100"],["Bob","200"]]` produced one row
with four columns instead of two rows with two columns each.

Change `AppendConfig.values` from `Vec<String>` to `Vec<Vec<String>>`
so row boundaries are preserved end-to-end. Single-row inputs via
`--values` and flat JSON arrays are wrapped in a single-element outer
vec for consistency.

Closes #311

* chore: trigger CLA re-check

* fix: warn on malformed --json-values input instead of silently ignoring

Address review feedback: print a warning to stderr when --json-values
cannot be parsed as a JSON array, rather than silently falling back
to an empty value set.
2026-03-13 13:13:07 -06:00
Justin Poehnelt 86ea6dea32 fix(auth): validate --subscription in gmail +watch and deduplicate PUBSUB_API_BASE (#441)
* fix(auth): validate --subscription in gmail +watch and deduplicate PUBSUB_API_BASE

- Move PUBSUB_API_BASE constant to helpers/mod.rs (shared by events/subscribe and gmail/watch)
- Add validate_resource_name on --subscription in gmail +watch parse_watch_args
- Replace remaining hardcoded Pub/Sub and Gmail API URLs with constants
- Add test for --subscription path traversal rejection

Closes #408

* fix: replace remaining hardcoded Pub/Sub URLs in subscribe.rs with PUBSUB_API_BASE

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-12 14:26:36 -06:00
Eduard Tanase 3dcf818cf1 fix(auth): refresh OAuth2 tokens in long-running watch/subscribe loops (#407)
* Fix token refresh for long-running helpers

* Reuse tokens within a single loop iteration

Remove redundant access_token() calls for acknowledge requests (both
loops) and per-message Gmail fetches.  The token obtained at the start
of the iteration / function is still valid and is now reused for all
HTTP calls within the same pass.

Addresses review feedback from gemini-code-assist.

* Address review: deduplicate FakeTokenProvider, refresh gmail token per message

- Move FakeTokenProvider to auth.rs behind #[cfg(test)] so both helper
  test modules share one definition.
- Refresh the Gmail token per message inside fetch_and_output_messages
  to guard against expiry during large batches.
- Update test expectations accordingly.

* Warn instead of printing misleading 'Cleanup complete' on token failure

Move the success message inside the token-refresh guard and add an else
branch that warns about potential orphaned resources.

* Reuse single gmail token per fetch_and_output_messages call

A single history batch won't approach the token's 1-hour lifetime,
so per-message refresh is unnecessary overhead. The outer watch_pull_loop
already refreshes tokens each iteration for long-running resilience.
2026-03-12 14:14:59 -06:00
Justin Poehnelt 510024f6d1 fix(auth): auto-recover from stale encrypted credentials after upgrade (#435)
* fix(auth): auto-recover from stale encrypted credentials after upgrade

When credentials.enc cannot be decrypted (e.g. after an upgrade that
changed the encryption key), automatically remove the stale file and
fall through to other credential sources (plaintext, ADC) instead of
hard-erroring. This breaks the stuck loop where logout+login couldn't
fix the issue.

Also sync .encryption_key file backup when the keyring has a valid key
but the file is missing, preventing future key loss if the keyring
becomes unavailable.

Fixes #389

* fix: log warnings instead of silently ignoring file operation errors

Address review feedback: log warnings when file removal or backup
creation fails, so users get clear feedback instead of silent failures.
Token cache cleanup now skips NotFound errors since they may not exist.

* refactor: use tokio::fs::remove_file for async consistency

Switch from std::fs::remove_file to tokio::fs::remove_file in the
async load_credentials_inner function to avoid blocking the runtime,
consistent with other file operations in the same function.

* refactor: reuse parse_credential_file and use atomic key backup

- Reuse parse_credential_file for encrypted creds instead of manual
  JSON parsing. This removes duplication and adds ServiceAccount support.
- Use save_key_file_exclusive (atomic) for key backup creation, with
  AlreadyExists race condition handling.
- Update stale comment about encrypted creds being AuthorizedUser only.

* refactor: centralize token cache filenames as constants

Extract hardcoded 'token_cache.json' and 'sa_token_cache.json' strings
into TOKEN_CACHE_FILENAME and SA_TOKEN_CACHE_FILENAME constants in
auth_commands.rs. Update all 5 reference sites in auth.rs and
auth_commands.rs to use the constants.

* refactor: unconditional key sync + parse_credential_file for all paths

- Unconditionally sync the .encryption_key file with the keyring value
  on every successful read, not just when file is missing. Prevents
  stale file backups causing decryption failures if keyring becomes
  unavailable later. Uses save_key_file (overwrite + fsync) instead
  of save_key_file_exclusive (create-only).

- Use parse_credential_file for plaintext credentials at default path,
  consistent with encrypted/ADC paths. Adds ServiceAccount support
  for credentials.json, not just AuthorizedUser.

- Updated test: keyring_backend_syncs_file_when_keyring_differs
  verifies file content is overwritten to match keyring key.

* revert: remove scope-creep changes (constants + plaintext SA support)

Move TOKEN_CACHE_FILENAME/SA_TOKEN_CACHE_FILENAME constants and
parse_credential_file for the plaintext credentials path to a
follow-up refactor PR — keep this PR focused on stale credential
auto-recovery.

* style: use tokio::fs::write in async test functions

Replace std::fs::write with tokio::fs::write().await in async test
functions for consistency with the async runtime.

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-12 11:59:46 -06:00
Anshul Garg e104106cd7 docs: warn about zsh ! history expansion in sheet range examples (#412)
* docs: warn about zsh \! history expansion in sheet range examples

Add a "Shell Tips" section to the gws-shared skill explaining that
zsh interprets \! inside single quotes as history expansion, which
mangles sheet ranges like 'Sheet1\!A1:B2'.

Replace single quotes with double quotes around sheet ranges
containing \! in:
- registry/recipes.yaml (source for generated recipe skills)
- src/helpers/sheets.rs (+read after_help examples)
- src/generate_skills.rs (shared skill template)
- Generated SKILL.md files for affected recipes and gws-sheets-read

Closes #268

* fix: escape double quotes in after_help string literal

The after_help string for +read uses a regular string literal, not a
raw string, so inner double quotes must be escaped with backslashes.
2026-03-12 10:15:51 -06:00
Malo Bourgon 9d937af8af feat(gmail): add --html flag for HTML email composition (#417)
Add --html flag to +send, +reply, +reply-all, and +forward, enabling
HTML email composition. When set, --body is treated as HTML content and
the Content-Type switches from text/plain to text/html.

For replies and forwards, the quoted/forwarded block matches Gmail web
UI fidelity: gmail_quote_container class, gmail_sendername structure,
mailto links in attribution and metadata, <div dir="ltr"> wrapper on
quoted content, and RFC 2822 dates reformatted to Gmail's human-friendly
style. When the original message has no HTML body, plain text is
HTML-escaped with <br> line breaks as a fallback (with a stderr
diagnostic).
2026-03-12 10:09:15 -06:00
github-actions[bot] 89f24b88ed style: cargo fmt 2026-03-12 16:03:11 +00:00
Abhi Ram Reddy Salammagari 247e27a876 feat(error): add structured exit codes for scriptable error handling (#428)
* feat(error): add structured exit codes for scriptable error handling

Replace the hardcoded `std::process::exit(1)` with a type-specific exit
code derived from the GwsError variant:

  0 — success
  1 — API error      (GwsError::Api)
  2 — auth error     (GwsError::Auth)
  3 — validation     (GwsError::Validation)
  4 — discovery      (GwsError::Discovery)
  5 — internal       (GwsError::Other)

This allows shell scripts to branch on failure type without parsing the
JSON error output:

  gws drive files list ...
  case $? in
    1) echo "API error — check your params" ;;
    2) echo "Auth error — run: gws auth login" ;;
    3) echo "Bad arguments" ;;
  esac

Changes:
- Add GwsError::exit_code() mapping variants to codes
- Update main() to call std::process::exit(err.exit_code())
- Document exit codes in gws --help (print_usage)
- Document exit codes in README under new Exit Codes section
- Add 6 unit tests including a regression guard asserting all codes are distinct

* refactor(error): replace magic exit-code numbers with named constants

Add EXIT_CODE_API/AUTH/VALIDATION/DISCOVERY/OTHER associated constants
on GwsError so callers and tests reference symbolic names rather than
bare integers. Update exit_code() match arms and all tests accordingly.
The distinctness test now validates the constants array directly.

Addresses code-review feedback requesting named constants.

* refactor(error): centralize exit code help text via EXIT_CODE_DOCUMENTATION

Add a module-level EXIT_CODE_DOCUMENTATION constant — a static slice of
(code, description) pairs built from the EXIT_CODE_* constants. Replace
the hardcoded println! block in print_usage() with a loop over this slice
so the help output is always in sync with the defined constants and cannot
drift out of date.

Addresses code-review feedback requesting a single source of truth.
2026-03-12 10:02:47 -06:00
Zsombor Szabo 087066f946 fix(auth): enable native keyring backends on top of #359 (#373)
* fix(auth): enable native keyring backends

* test(auth): serialize config dir env test

* fix(auth): scope native keyring backends to desktop targets

* fix(ci): tolerate read-only gemini review tokens

* test(credential_store): cover race winner sync path
2026-03-12 09:58:52 -06:00
drassi adbca874ab fix: handle array-of-arrays in CSV formatter (#288)
* fix: handle array-of-arrays in CSV formatter

The CSV formatter assumed all items were JSON objects when collecting
column names. APIs that return arrays of arrays (e.g. Sheets values)
produced empty newlines instead of data.

Add an early return path for non-object arrays that emits each inner
array's elements as CSV cells directly, mirroring the table formatter's
existing "array of non-objects" handling.

Fixes #283

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add coverage for flat scalar CSV formatting

---------

Co-authored-by: Dan Rassi <129646+drassi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:26:06 -06:00
Malo Bourgon 4d4b09f9cc feat(gmail): add recipient management flags and extract shared message builder (#362)
* feat(gmail): add --cc, --bcc, --to recipient management flags

Add --cc and --bcc to +send, --to and --bcc to +reply and +reply-all,
and --bcc to +forward. This brings all four Gmail helpers to feature
parity for basic recipient management.

Key behaviors:
- --to on reply/reply-all is additive (appends to auto-computed To)
- --remove only affects auto-computed recipients, not explicit flags
- Dedup with priority To > CC > BCC via new dedup_recipients()
- Validation deferred until after all additions and dedup
- Empty/whitespace --cc/--bcc/--to filtered to None at parse time
- BCC header included in raw message (Gmail API strips before delivery)

Also includes:
- CAUTION boxes in all four SKILL.md files
- SendConfig visibility narrowed to pub(super)
- Consistent "email address(es)" wording across clap help and SKILL.md

* refactor(gmail): extract shared MessageBuilder and fix pre-existing issues

Extract duplicated header-construction logic from send.rs, reply.rs, and
forward.rs into a shared MessageBuilder in mod.rs. This centralizes CRLF
header-injection sanitization and RFC 2047 subject encoding that were
previously inconsistent across the three paths.

Additional changes:
- Fix silent auth failure in send.rs (Err(_) swallowed all auth errors)
- Fix try_get_one error swallowing in parse_reply_args (explicit match
  on MatchesError::UnknownArgument, propagate unexpected errors)
- Extract shared helpers: build_references, parse_optional_trimmed,
  encode_header_value, sanitize_header_value
- Introduce ForwardEnvelope (analogous to ReplyEnvelope)
- Introduce ThreadingHeaders to group in_reply_to/references
2026-03-10 14:13:32 -06:00
Justin Poehnelt 8d89325a8b feat(credential_store): add GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND env var (#359)
* feat(credential_store): add GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND env var

Add gogcli-style backend selection for encryption key storage:
- keyring (default): OS keyring with file fallback
- file: .encryption_key file only (Docker/CI/headless)

Never delete .encryption_key — it always serves as a durable fallback
for environments where the keyring is ephemeral. When generating new
keys with backend=keyring, save to both keyring and file.

Extracts KeyringProvider trait + resolve_key() for testability.
25 tests covering both backends and all edge cases.

Fixes #344

* chore: regenerate skills [skip ci]

* fix(credential_store): use O_EXCL for race-safe key generation

Use create_new(true) (O_EXCL on Unix, CREATE_NEW on Windows) when
generating a new encryption key file. If another process wins the
race, read their key instead. Platform-independent.

* fix(credential_store): sync winner's key into keyring after file race

When two processes race to create the encryption key file, the loser
now syncs the winner's key back into the keyring. Without this, the
keyring and file could permanently diverge.

* test(credential_store): add 9 tests covering file exclusion, env parsing, and race paths

- save_key_file_exclusive: creates new file, rejects existing
- save_key_file: overwrites existing
- ensure_key_dir: creates nested dirs
- KeyringBackend: file/FILE/invalid parsing
- Race loser: syncs winner key to keyring
- Race loser: corrupt file gets overwritten

* feat(credential_store): security and robustness hardening

1. Warn on unrecognized KEYRING_BACKEND values instead of silent default
2. fsync after key file writes for crash durability
3. Zeroize decoded key material from heap after copy
4. Warn if key file has overly permissive Unix permissions (mode & 077)
5. Log which keyring backend was selected to stderr
6. Expose keyring_backend in 'gws auth status' JSON output

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-10 11:17:35 -06:00
Malo Bourgon 5e7d1200df fix(gmail): bring +forward behavior in line with Gmail web UI (#353)
- Pass original threadId to keep forward in sender's thread
- Add In-Reply-To and References headers for RFC 5322 compliance
- Add blank line between forwarded message metadata and body
- Remove spurious closing delimiter from forwarded message block
- Update SKILL.md to remove outdated threading tip
- Add test for References chain construction

Closes #88
2026-03-09 17:47:35 -06:00
Justin Poehnelt 06aa698e23 fix(auth): format and deduplicate dynamic scope fallback (#352)
* fix(auth): fall back to Discovery docs when `-s` specifies services not in static scope lists

When `gws auth login -s chat` (or any service not in the 7 static
scope lists) is used, the static filter returns no matching scopes.

Add a dynamic fallback that detects unmatched services and fetches
their OAuth scopes from the Google Discovery API. This leverages the
existing `fetch_discovery_document` with 24h caching.

Fixes #236

* refactor: optimize find_unmatched_services and parallelize Discovery fetches

Address review feedback:
- Avoid per-service HashSet allocation in find_unmatched_services by
  collecting matched services first then computing the difference.
- Use futures_util::future::join_all to fetch Discovery docs in
  parallel instead of sequentially.

* refactor: extract map_service_to_scope_prefix to deduplicate alias mapping

Share the service-name-to-scope-prefix mapping between
scope_matches_service and find_unmatched_services via a single helper.

* fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes

- Break long method chain in extract_scopes_from_doc to pass cargo fmt
- Deduplicate dynamic scopes in augment_with_dynamic_scopes to prevent
  duplicate entries when dynamic results overlap with static ones

---------

Co-authored-by: Frank <qwer4488999@gmail.com>
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-09 17:36:49 -06:00
zerone0x 2782cf101e fix: use gmail.readonly scope in +triage to avoid metadata scope 403 (#304)
The +triage helper uses the `q` query parameter when listing messages,
but Gmail's metadata scope does not support `q` and returns 403. When a
user's OAuth token includes both gmail.metadata and gmail.modify scopes,
the API may resolve to the metadata code path and reject the query.

Switch +triage from gmail.modify to gmail.readonly, which is the
minimum scope that supports query filtering and aligns with the
read-only nature of the triage command.

Fixes #265
2026-03-09 17:08:34 -06:00
Justin Poehnelt 5872dbe474 fix(credential_store): stop persisting encryption key file when keyring is available (#345)
Previously, get_or_create_key() unconditionally wrote the encryption key
to ~/.config/gws/.encryption_key on first run, even when the OS keyring
was available. This left the key material on disk as a plain file,
making credentials portable by copying the config directory.

Changes:
- Extract save_key_file() helper to deduplicate file-writing logic
- On keyring read success: delete stale .encryption_key (migration)
- On NoEntry + existing file: migrate key into keyring, then delete file
- On NoEntry + new key: try keyring first, only write file as fallback

Fixes #344

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-09 15:51:05 -06:00
jonameijers 08716f8bcd fix: RFC 2047 encode non-ASCII email subjects in +send helper (#322)
* fix: RFC 2047 encode non-ASCII email subjects in +send helper

* chore: add changeset for RFC 2047 fix

* fix: use standard Base64 and fold long encoded-words per RFC 2047

* fix: chunk at char boundaries to avoid splitting multi-byte UTF-8

* chore: fix changeset package

Fix garbled non-ASCII email subjects in gmail +send by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers.

---------

Co-authored-by: Jona Meijers <jonameijers@gmail.com>
Co-authored-by: Justin Poehnelt <justin.poehnelt@gmail.com>
2026-03-09 11:48:06 -06:00
Si Zengyu 7d15365518 feat(gmail): add +reply, +reply-all, and +forward helpers (#105)
* feat(gmail): add +reply, +reply-all, and +forward helper commands

Add first-class reply and forward support to the Gmail helpers,
addressing the gap described in #88. These commands handle the
complex RFC 2822 threading mechanics (In-Reply-To, References,
threadId) that agents and CLI users struggle with today.

New commands:
- +reply: reply to a message with automatic threading
- +reply-all: reply to all recipients with --remove/--cc support
- +forward: forward a message with quoted original content

* fix(gmail): encode message_id in URL path and fix auth signature

- Use crate::validate::encode_path_segment() on message_id in
  fetch_message_metadata URL construction per AGENTS.md rules
- Update auth::get_token calls to pass None for the new account
  parameter added on main

* refactor(gmail): extract send_raw_email and deduplicate handlers

- Add send_raw_email() to mod.rs: shared encode→json→auth→execute
  pattern for sending raw RFC 2822 messages via users.messages.send
- Simplify handle_reply: delegate send logic to send_raw_email
- Simplify handle_forward: delegate send logic to send_raw_email

Addresses code duplication feedback from PR review.

* fix(gmail): register --dry-run flag on reply/forward commands

The handlers read matches.get_flag("dry-run") but the flag was missing
from the clap command definitions, so it always returned false. Now
dry-run works for +reply, +reply-all, and +forward.

* chore: add changeset for gmail reply/forward feature

* style: apply cargo fmt formatting

* fix(gmail): register --dry-run flag on +send command

Same class of bug fixed for +reply/+reply-all/+forward — the handler
reads matches.get_flag("dry-run") but the arg was not registered.

* fix(gmail): honor Reply-To header and use exact address matching

- Prefer Reply-To over From when selecting reply recipients, fixing
  incorrect routing for mailing lists and support systems
- Use exact email address comparison instead of substring matching
  for --remove filtering and sender deduplication, preventing
  unintended recipient removal (e.g. ann@ no longer drops joann@)

* test(gmail): add comprehensive coverage for reply address handling

- extract_email: malformed input (no closing bracket), empty string,
  whitespace-only
- build_reply_all_recipients: display-name sender exclusion,
  --remove with display name, extra --cc, CC becomes None when all
  filtered, case-insensitive sender exclusion

* Improves reply-all recipient deduplication

Corrects how `build_reply_all_recipients` handles multi-address `Reply-To` headers.
Previously, only the first address from `Reply-To` was used for deduplication, leading to potential redundancy by including those addresses in the `Cc` field.
The updated logic now parses all addresses in `Reply-To`, ensuring they are fully moved to the `To` field and properly excluded from `Cc`.

* style(gmail): add missing Apache 2.0 copyright headers

reply.rs and forward.rs were missing the copyright header that all
other source files in the repo include.

* fix(gmail): use try_get_one for optional --remove arg in +reply

parse_reply_args used get_one("remove") which panics when called
from +reply (which does not register --remove). Switch to
try_get_one to safely return None for unregistered args.

* feat(gmail): support --dry-run without auth for reply/forward commands

Skip auth and message fetch when --dry-run is set by using placeholder
OriginalMessage data. This lets users preview the request structure
without needing credentials.

* fix(gmail): use RFC-aware mailbox list parsing for recipient splitting

Replace naive comma-split with split_mailbox_list that respects
quoted strings, so display names containing commas like
"Doe, John" <john@example.com> are handled correctly in reply-all
recipient parsing, deduplication, and --remove filtering.

* fix(gmail): handle escaped quotes in mailbox list splitting

split_mailbox_list toggled quote state on every `"` without accounting
for backslash-escaped quotes (`\"`), causing display names like
`"Doe \"JD, Sr\""` to split incorrectly at interior commas.

Track `prev_backslash` so `\"` inside quoted strings is treated as a
literal quote character rather than a delimiter toggle. Double
backslashes (`\\`) are handled correctly as well.

* fix(gmail): address PR review feedback for reply/forward helpers

- Use reqwest .query() for metadata params per AGENTS.md convention
- Add MIME-Version and Content-Type headers to raw messages
- Add --from flag to +reply, +reply-all, +forward for send-as/alias
- Narrow ReplyConfig/ForwardConfig visibility to pub(super)
- Refactor create_reply_raw_message args into ReplyEnvelope struct

* fix(gmail): address review feedback for reply/forward helpers

- Exclude authenticated user's own email from reply-all CC by
  fetching user profile via Gmail API
- Use format=full to extract full plain-text body instead of
  truncated snippet for quoting and forwarding
- Deduplicate CC addresses using a HashSet
- Reuse auth token from message fetch in send_raw_email to
  eliminate double auth round-trip
- Propagate auth errors in send_raw_email instead of silently
  falling back to unauthenticated requests
- Use consistent CRLF line endings in quoted and forwarded
  message bodies per RFC 2822

* fix(gmail): Gmail reply and forward helpers

* fix(gmail): refactor shared reply-forward helpers

* Preserve repeated Gmail address headers

* chore: regenerate skills [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-09 11:47:02 -06:00
Max Headley f083eb9af0 fix: Improve auth setup project-creation error handling and retry UX (#95)
* Improve setup project creation error recovery

* Add post-setup login continuation flow

---------

Co-authored-by: mkh09353 <6936686+mkh09353@users.noreply.github.com>
2026-03-09 11:30:31 -06:00
github-actions[bot] 6f67da39b6 style: cargo fmt 2026-03-09 00:21:14 +00:00
Shane Huntley 4d41e52198 fix(auth): prioritize local project configuration over global ADC for quota attribution (#295)
* fix(auth): prioritize local project configuration over global ADC for quota attribution

* chore: add changeset for project ID priority fix
2026-03-08 18:20:56 -06:00
Steve Bazyl dd3fc9074d fix!: Remove MCP server mode (#275)
* BREAKING CHANGE: Remove MCP server mode

* Add changeset file
2026-03-06 11:33:23 -07:00
Justin Poehnelt d34576c5c7 chore: Remove a subset of skills and recipes along with their service entries and registry references (#254) 2026-03-05 19:51:59 -08:00
Justin Poehnelt d6372105eb feat!: remove multi-account, DWD, and impersonation support (#253)
* feat!: remove multi-account, DWD, and impersonation support

BREAKING CHANGE: Remove domain-wide delegation, multi-account support,
and impersonation from the CLI authentication flow.

Removed:
- `gws auth list` and `gws auth default` commands
- `--account` flag from `gws auth login` and `gws auth logout`
- `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var
- `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env var
- Per-account credential storage (accounts.json registry)
- Service account impersonation (subject/DWD)

Preserved:
- `GOOGLE_WORKSPACE_CLI_TOKEN` (raw access token)
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` (SA key path)
- `GOOGLE_WORKSPACE_CLI_CLIENT_ID` / `CLIENT_SECRET` (OAuth config)
- `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` (config dir override)

* chore: update changeset description

* docs: remove multi-account and DWD references from docs

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-05 22:43:43 -05:00
Steve Bazyl e1505afe12 chore: Remove dwd support (#250)
* chore: Remove dwd support

* Add changeset file

* chore: regenerate skills [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-05 19:26:27 -08:00
Adeel Khan 54b3b31728 fix: quota header discovery (#242) 2026-03-05 18:03:20 -08:00
Frank c86b964de4 fix: respect account selection in MCP server and CLI --account flag (#223)
* fix: respect account selection in MCP server and CLI --account flag (#221, #181)

MCP server now reads GOOGLE_WORKSPACE_CLI_ACCOUNT env var and passes it
to get_token instead of always using None (default account).

CLI filter_args_for_subcommand now dynamically locates the service name
instead of hardcoding skip(2), fixing --account before service name
causing unrecognized subcommand errors.

* fix: skip --api-version in first_arg detection

The first_arg loop only skipped --account but not --api-version,
so `gws --api-version v3 drive ...` would misidentify --api-version
as the service name. Now both global flags are consistently skipped.
2026-03-05 16:31:57 -08:00
Justin Poehnelt 322529d8a9 fix: document all environment variables and enable CONFIG_DIR override (#222)
* docs: document all environment variables and enable CONFIG_DIR override (#171)

* docs: clarify env vars are trusted inputs in AGENTS.md

* chore: add Gemini Code Assist style guide

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-05 16:09:01 -08:00
Shreyas Karnik 6daf90d331 fix(mcp): conditionally include body/upload in tool schemas, drop empty body on execution (#213)
* fix(mcp): conditionally include body/upload in full-mode tool schemas and drop empty body on execution

Full-mode tool schemas now only include `body` when the Discovery Document
method defines a request body, and `upload` when `supportsMediaUpload` is
true. This prevents LLMs from hallucinating these fields on GET-only methods.

Additionally, empty body objects (`{}`) are filtered out before execution
in both compact and full modes, and empty upload strings are ignored. LLMs
commonly send "body": {} on read-only methods, which causes Google APIs to
return HTTP 400.

* style: cargo fmt and add changeset for MCP tool schema fix

* fix(mcp): conditionally include page_all only for paginated methods

Only include the page_all property in full-mode tool schemas when the
method has a pageToken parameter, preventing LLMs from attempting
pagination on non-paginable methods.

* docs: update changeset to include page_all conditional change
2026-03-05 15:37:17 -08:00