chore: import upstream snapshot with attribution
Automation / Format (push) Waiting to run
Automation / File Labeler (push) Waiting to run
Automation / Gemini Review (push) Waiting to run
Automation / Gemini Reviewed (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Nix (push) Blocked by required conditions
CI / Lint (push) Blocked by required conditions
CI / Cargo Deny (push) Blocked by required conditions
CI / Verify Skills (push) Blocked by required conditions
CI / Lint Skills (push) Blocked by required conditions
CI / Build (Linux x86_64) (push) Blocked by required conditions
CI / Build (macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
CI / Build (macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Blocked by required conditions
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
CI / API Smoketest (push) Blocked by required conditions
Coverage / Coverage (push) Waiting to run
Policy / Policy Check (push) Waiting to run
Release (Changeset) / Release (push) Waiting to run
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:06 +08:00
commit 76a9e7a0cc
222 changed files with 47091 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
---
description: Writing and editing VHS `.tape` files for terminal demo GIFs
---
# VHS Tape Files
[VHS](https://github.com/charmbracelet/vhs) records terminal sessions into GIFs/MP4s/WebMs from `.tape` scripts. Run with `vhs demo.tape`.
## Critical Syntax Rules
### Type command and inline directives
`Type`, `Sleep`, `Enter` are **separate directives on the same line**, delimited by the closing `"` of the `Type` string. The most common bug is forgetting to close the `Type` string, which causes `Sleep`/`Enter` to be typed literally into the terminal.
```
# ✅ CORRECT — closing " before Sleep
Type "echo hello" Sleep 300ms Enter
# ❌ WRONG — Sleep and Enter are typed as literal text
Type "echo hello Sleep 300ms Enter
```
### Type with @speed override
Override typing speed per-command with `@<time>` immediately after `Type` (no space):
```
Type@80ms '{"pageSize": 2}' Sleep 100ms
```
### Quoting
- Double quotes `"..."` are the standard Type delimiter
- Single quotes `'...'` also work and are useful when the typed content contains double quotes (e.g. JSON)
- Escape quotes inside strings with backticks: `` Type `VAR="value"` ``
- When building shell commands with nested quotes, split across multiple `Type` lines:
```
Type "gws drive files list --params '" Sleep 100ms
Type@80ms '{"pageSize": 2, "fields": "nextPageToken,files(id)"}' Sleep 100ms
Type "' --page-all" Sleep 300ms Enter
```
> **Pitfall**: Every `Type` line that is followed by `Sleep` or `Enter` on the same line MUST close its string first. Audit each line to ensure the quote is closed before any directive.
## Settings (top of file only)
Settings must appear before any non-setting command (except `Output`). `TypingSpeed` is the only setting that can be changed mid-tape.
```
Output demo.gif
Set Shell "bash"
Set FontSize 14
Set Width 1200
Set Height 1200
Set Theme "Catppuccin Mocha"
Set WindowBar Colorful
Set WindowBarSize 40
Set TypingSpeed 40ms
Set Padding 20
```
## Common Commands
| Command | Example | Notes |
|---|---|---|
| `Output` | `Output demo.gif` | `.gif`, `.mp4`, `.webm` |
| `Type` | `Type "ls -la"` | Type characters |
| `Type@<time>` | `Type@80ms "slow"` | Override typing speed |
| `Sleep` | `Sleep 2s`, `Sleep 300ms` | Pause recording |
| `Enter` | `Enter` | Press enter |
| `Hide` / `Show` | `Hide` ... `Show` | Hide setup commands |
| `Ctrl+<key>` | `Ctrl+C` | Key combos |
| `Tab`, `Space`, `Backspace` | `Tab 2` | Optional repeat count |
| `Up`, `Down`, `Left`, `Right` | `Up 3` | Arrow keys |
| `Wait` | `Wait /pattern/` | Wait for regex on screen |
| `Screenshot` | `Screenshot out.png` | Capture frame |
| `Env` | `Env FOO "bar"` | Set env var |
| `Source` | `Source other.tape` | Include another tape |
| `Require` | `Require jq` | Assert program exists |
## Hide/Show for Setup
Use `Hide`/`Show` to run setup commands (e.g. setting `$PATH`, clearing screen) without recording them:
```
Hide
Type "export PATH=$PWD/target/release:$PATH" Enter
Type "clear" Enter
Sleep 2s
Show
```
## Checklist When Editing Tape Files
1. **Every `Type` string must be closed** before `Sleep`/`Enter` on the same line
2. **Multi-line Type sequences** that build a single shell command: ensure the final line closes its string and includes `Enter`
3. **Sleep durations** after commands should be long enough for the command to finish (network calls may need 8s+)
4. **Settings go at the top** — only `TypingSpeed` can appear later
5. **Test locally** with `vhs <file>.tape` before committing
+73
View File
@@ -0,0 +1,73 @@
---
description: Verify all skills/*/SKILL.md files against actual CLI output for accuracy
---
# Verify Skills
Ensure every `skills/*/SKILL.md` file is accurate and optimized for AI agent consumption.
## Steps
1. **List all skill files**
```bash
find skills -name SKILL.md | sort
```
2. **Get top-level help for every service**
// turbo
```bash
for svc in drive sheets gmail calendar admin admin-reports docs slides tasks people chat vault groupssettings reseller licensing apps-script; do
echo "=== $svc ==="
./target/debug/gws $svc --help 2>&1
echo
done
```
3. **Get sub-resource help for key services** (spot-check method names used in examples)
// turbo
```bash
./target/debug/gws drive files --help 2>&1
./target/debug/gws gmail users messages --help 2>&1
./target/debug/gws sheets spreadsheets --help 2>&1
./target/debug/gws sheets spreadsheets values --help 2>&1
./target/debug/gws calendar events --help 2>&1
./target/debug/gws people people --help 2>&1
./target/debug/gws chat spaces --help 2>&1
./target/debug/gws vault matters --help 2>&1
./target/debug/gws admin users --help 2>&1
./target/debug/gws tasks tasks --help 2>&1
```
4. **For each SKILL.md, verify the following against the CLI `--help` output:**
- [ ] **Resource names** match exactly (e.g., `files`, `spreadsheets`, `users`)
- [ ] **Method names** match exactly (e.g., `list`, `insert`, `batchUpdate`, `getContent`)
- [ ] **Nested resource paths** are correct (e.g., `spreadsheets values get`, not `values get`)
- [ ] **Alias** mentioned in the file matches `services.rs` (e.g., `gws script` for apps-script)
- [ ] **API version** in the header is correct
- [ ] **Example commands** use valid `--params` and `--json` flag syntax
- [ ] **No OAuth scopes section** — scopes should not be listed in skill files
- [ ] **Tips section** contains accurate, actionable advice
5. **Cross-check `shared/SKILL.md`** covers:
- [ ] `--fields` / field mask syntax
- [ ] CLI syntax (`--params`, `--json`, `--output`, `--upload`, `--page-all`, `--page-limit`, `--page-delay`)
- [ ] Authentication (`GOOGLE_WORKSPACE_CLI_CREDENTIALS`, `GOOGLE_WORKSPACE_API_KEY`)
- [ ] Auto-pagination (`--page-all`) with NDJSON output
- [ ] `gws schema <method>` introspection
- [ ] Error handling JSON structure
- [ ] Binary download with `--output`
- [ ] Version override (`--api-version`, colon syntax)
6. **Fix any issues found** — update the SKILL.md files directly.
7. **Rebuild and re-verify** if any examples were changed.
// turbo
```bash
cargo build 2>&1
```
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---
Add `--range` flag to `sheets +append` for targeting specific sheet tabs
+5
View File
@@ -0,0 +1,5 @@
{
"attribution": {
"commit": ""
}
}
+29
View File
@@ -0,0 +1,29 @@
# gws — Google Workspace CLI
# Copy this file to .env and uncomment the variables you need.
# All variables are optional. See README.md for details.
# ── Authentication ────────────────────────────────────────────────
# Pre-obtained OAuth2 access token (highest priority; bypasses all credential loading)
# GOOGLE_WORKSPACE_CLI_TOKEN=
# Path to OAuth credentials JSON (user or service account)
# GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=
# ── OAuth Client ──────────────────────────────────────────────────
# OAuth client ID and secret (alternative to saving client_secret.json)
# GOOGLE_WORKSPACE_CLI_CLIENT_ID=
# GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=
# ── Configuration ─────────────────────────────────────────────────
# Override the config directory (default: ~/.config/gws)
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR=
# ── Model Armor (response sanitization) ──────────────────────────
# Default Model Armor template (overridden by --sanitize flag)
# GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE=
# Sanitization mode: warn (default) or block
# GOOGLE_WORKSPACE_CLI_SANITIZE_MODE=warn
# ── Helpers ───────────────────────────────────────────────────────
# GCP project ID fallback for gmail watch and events subscribe (overridden by --project)
# GOOGLE_WORKSPACE_PROJECT_ID=
+7
View File
@@ -0,0 +1,7 @@
code_review:
comment_severity_threshold: HIGH
pull_request_opened:
help: false
summary: true
code_review: true
include_drafts: false
+39
View File
@@ -0,0 +1,39 @@
# Code Review Style Guide
## Project Architecture
`gws` is a Rust CLI that dynamically generates commands from Google Discovery Documents at runtime. It does NOT use generated Rust crates (`google-drive3`, etc.) for API interaction. Do not suggest adding API-specific crates to `Cargo.toml`.
For additional context, read `AGENTS.md`.
## Security: Trusted vs Untrusted Inputs
This CLI is frequently invoked by AI/LLM agents. CLI arguments may be adversarial.
- **CLI arguments (untrusted)** — Must validate paths against traversal (`../../`), reject control characters, percent-encode URL path segments, and use `reqwest .query()` for query parameters. Validators: `validate_safe_output_dir()`, `validate_safe_dir_path()`, `encode_path_segment()`, `validate_resource_name()`.
- **Environment variables (trusted)** — Set by the user in their shell profile, `.env` file, or deployment config. Do NOT flag missing path validation on environment variable values. This is consistent with `XDG_CONFIG_HOME`, `CARGO_HOME`, etc.
## Test Coverage
The `codecov/patch` check requires new/modified lines to be covered by tests. Prefer extracting testable helper functions over embedding logic in `main`/`run`. Tests should cover both happy paths and rejection paths (e.g., pass `../../.ssh` and assert `Err`).
## Changesets
Every PR must include a `.changeset/<name>.md` file. The package name **must** be `"@googleworkspace/cli"` (not `"googleworkspace-cli"`). Use `patch` for fixes/chores, `minor` for features, `major` for breaking changes.
## PR Scope
Review comments must stay within the PR's stated scope. If you spot an improvement opportunity that is unrelated to the PR's purpose (e.g., refactoring constants, adding support for a different credential type, making an unrelated function atomic), mark it as a **follow-up** suggestion — not a blocking review comment. Do not request changes that expand the PR beyond its original intent.
Examples of scope creep to avoid:
- A bug-fix PR should not grow into a refactoring PR.
- Adding constants for strings used elsewhere is a separate cleanup task.
- Making a pre-existing function atomic is an enhancement, not a fix for the current PR.
## Severity Calibration
Mark issues as **critical** only when they cause data loss, security vulnerabilities, or incorrect behavior under normal conditions. Theoretical failures in infallible system APIs (e.g., `tokio::signal::ctrl_c()` registration) are **low** severity — do not label them critical. Contradicting a prior review suggestion (e.g., suggesting `expect()` then flagging `expect()` as wrong) erodes trust; verify consistency with earlier comments before posting.
## Helper Commands (`+verb`)
Helpers are handwritten commands that provide value Discovery-based commands cannot: multi-step orchestration, format translation, or multi-API composition. **Do not accept helpers that wrap a single API call, add flags to expose data already in the API response, or re-implement Discovery parameters as custom flags.** See [`src/helpers/README.md`](../src/helpers/README.md) for full guidelines and anti-patterns.
+10
View File
@@ -0,0 +1,10 @@
# Codeowners
# Core engine code strictly requires your review
# Isolates agents to `skills/` or `src/helpers/` unless absolutely necessary
/src/main.rs @jpoehnelt
/src/executor.rs @jpoehnelt
/src/discovery.rs @jpoehnelt
/src/commands.rs @jpoehnelt
/src/auth.rs @jpoehnelt
/src/schema.rs @jpoehnelt
+16
View File
@@ -0,0 +1,16 @@
## Description
Please include a summary of the change and which issue is fixed. If adding a new feature or command, please include the output of running it with `--dry-run` to prove the JSON request body matches the Discovery Document schema.
**Dry Run Output:**
```json
// Paste --dry-run output here if applicable
```
## Checklist:
- [ ] My code follows the `AGENTS.md` guidelines (no generated `google-*` crates).
- [ ] I have run `cargo fmt --all` to format the code perfectly.
- [ ] I have run `cargo clippy -- -D warnings` and resolved all warnings.
- [ ] I have added tests that prove my fix is effective or that my feature works.
- [ ] I have provided a Changeset file (e.g. via `pnpx changeset`) to document my changes.
+78
View File
@@ -0,0 +1,78 @@
# Labels applied to PRs based on changed files.
# Used by the actions/labeler action in .github/workflows/automation.yml
"area: auth":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/auth.rs
- crates/google-workspace-cli/src/auth_commands.rs
- crates/google-workspace-cli/src/setup.rs
- crates/google-workspace-cli/src/accounts.rs
- crates/google-workspace-cli/src/credential_store.rs
- crates/google-workspace-cli/src/token_storage.rs
- crates/google-workspace-cli/src/oauth_config.rs
"area: discovery":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/discovery.rs
- crates/google-workspace-cli/src/services.rs
- crates/google-workspace/src/discovery.rs
- crates/google-workspace/src/services.rs
"area: http":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/executor.rs
- crates/google-workspace-cli/src/client.rs
- crates/google-workspace/src/client.rs
"area: tui":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/setup_tui.rs
"area: mcp":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/mcp_server.rs
"area: skills":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/generate_skills.rs
- skills/**
"area: docs":
- changed-files:
- any-glob-to-any-file:
- "*.md"
- docs/**
"area: distribution":
- changed-files:
- any-glob-to-any-file:
- .github/workflows/release.yml
- .github/workflows/release-changesets.yml
- dist-workspace.toml
- Cargo.toml
- crates/*/Cargo.toml
"area: core":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace-cli/src/main.rs
- crates/google-workspace-cli/src/commands.rs
- crates/google-workspace-cli/src/error.rs
- crates/google-workspace-cli/src/formatter.rs
- crates/google-workspace-cli/src/fs_util.rs
- crates/google-workspace-cli/src/helpers/**
- crates/google-workspace-cli/src/text.rs
- crates/google-workspace-cli/src/validate.rs
- crates/google-workspace-cli/src/schema.rs
"crate: google-workspace":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace/src/**
- crates/google-workspace/Cargo.toml
+45
View File
@@ -0,0 +1,45 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Audit
on:
push:
branches: [main]
paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml']
pull_request:
branches: [main]
paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml']
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install cargo-audit
uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov
with:
tool: cargo-audit
- name: Run cargo audit
run: cargo audit
+142
View File
@@ -0,0 +1,142 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Automation
on:
push:
branches: [main]
pull_request_target:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
format:
name: Format
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
components: rustfmt
- name: Run cargo fmt
run: cargo fmt --all
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "style: cargo fmt" && git push
file-labeler:
name: File Labeler
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
repo-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
sync-labels: true
gemini-review:
name: Gemini Review
if: >-
github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
concurrency:
group: gemini-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- name: Remove reviewed label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
name: 'gemini: reviewed',
});
} catch (e) {
// Label not present — ignore
}
- name: Debounce
run: sleep 60
- name: Trigger Gemini Code Assist review
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '/gemini review',
});
gemini-reviewed:
name: Gemini Reviewed
if: >-
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'gemini-code-assist[bot]'
runs-on: ubuntu-latest
steps:
- name: Add reviewed label if review matches HEAD
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (context.payload.review.commit_id !== pr.data.head.sha) {
console.log(`Review is for ${context.payload.review.commit_id} but HEAD is ${pr.data.head.sha} — skipping label`);
return;
}
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: ['gemini: reviewed'],
});
} catch (e) {
if (e.status === 403) {
console.log(`Token cannot add labels for this review event (${e.message}) — skipping`);
return;
}
throw e;
}
+429
View File
@@ -0,0 +1,429 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
CARGO_TERM_COLOR: always
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "true"
jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
rust: ${{ steps.filter.outputs.rust }}
nix: ${{ steps.filter.outputs.nix }}
skills: ${{ steps.filter.outputs.skills }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
with:
filters: |
rust:
- '**/*.rs'
- 'Cargo.toml'
- 'crates/*/Cargo.toml'
- 'Cargo.lock'
- 'build.rs'
- '.cargo/**'
nix:
- 'flake.nix'
- 'flake.lock'
skills:
- 'skills/**'
test:
name: Test
needs: changes
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Setup sccache
id: sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
if sccache --start-server 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
else
echo "::warning::sccache server failed to start, building without cache"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: test-${{ matrix.os }}
- name: Run tests
run: cargo test --workspace --verbose
nix:
name: Nix
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.nix == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@d96bc962e61b3049ce8128d03d57a1144fa96539 # main
- name: Magic Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@cec65ff6f104850203b152861d3f9e5f1747885d # main
- name: Check flake
run: nix flake check
- name: Build flake
run: nix build .
lint:
name: Lint
needs: changes
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
components: rustfmt, clippy
- name: Setup sccache
id: sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
if sccache --start-server 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
else
echo "::warning::sccache server failed to start, building without cache"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: lint
- name: Check formatting
run: |
if ! cargo fmt --all -- --check; then
echo "::error::Cargo fmt failed. Please run 'cargo fmt --all' locally and commit the changes."
exit 1
fi
- name: Clippy
run: cargo clippy --workspace -- -D warnings
deny:
name: Cargo Deny
needs: changes
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Cargo deny
uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15
with:
command: check
skills:
name: Verify Skills
needs: changes
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Setup sccache
id: sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
if sccache --start-server 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
else
echo "::warning::sccache server failed to start, building without cache"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: skills
- name: Regenerate skills
run: cargo run -- generate-skills --output-dir skills
- name: Check for drift
run: |
if ! git diff --exit-code skills/; then
echo "::warning::Skills are out of date — the hourly auto-sync PR will fix this automatically."
fi
lint-skills:
name: Lint Skills
needs: changes
if: needs.changes.outputs.skills == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Validate skills
run: |
failed=0
for skill_dir in skills/*/; do
if ! uvx --from skills-ref@0.1.1 agentskills validate "$skill_dir"; then
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
echo "::error::One or more skills failed validation."
exit 1
fi
build-linux:
name: Build (Linux x86_64)
needs: changes
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
targets: x86_64-unknown-linux-gnu
- name: Setup sccache
id: sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
if sccache --start-server 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
else
echo "::warning::sccache server failed to start, building without cache"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: build-x86_64-unknown-linux-gnu
cache-targets: "false"
- name: Build
run: cargo build --release --target x86_64-unknown-linux-gnu
- name: Upload binary
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: gws-linux-x86_64
path: target/x86_64-unknown-linux-gnu/release/gws
retention-days: 1
build:
name: Build
needs: [smoketest, changes]
if: |
always() && !cancelled() && !failure()
&& (needs.changes.outputs.rust == 'true' || github.event_name == 'push')
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
targets: ${{ matrix.target }}
- name: Setup sccache
id: sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
if sccache --start-server 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
else
echo "::warning::sccache server failed to start, building without cache"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: build-${{ matrix.target }}
cache-targets: "false"
- name: Install cross-compilation tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Disable Windows Defender scanning for cargo
if: runner.os == 'Windows'
run: |
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.cargo"
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.rustup"
Add-MpPreference -ExclusionPath "${{ github.workspace }}\target"
- name: Build
run: cargo build --release --target ${{ matrix.target }}
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
smoketest:
name: API Smoketest
needs: build-linux
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download binary
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: gws-linux-x86_64
path: ./bin
- name: Make binary executable
run: chmod +x ./bin/gws
- name: Decode credentials
env:
GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
run: |
if [ -z "$GOOGLE_CREDENTIALS_JSON" ]; then
echo "::error::GOOGLE_CREDENTIALS_JSON secret is not set"
exit 1
fi
echo "$GOOGLE_CREDENTIALS_JSON" | base64 -d > /tmp/credentials.json
- name: Smoketest — help
run: ./bin/gws --help
- name: Smoketest — schema introspection
run: ./bin/gws schema drive.files.list | jq -e '.httpMethod'
- name: Smoketest — Drive files list
env:
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
run: |
./bin/gws drive files list \
--params '{"pageSize": 1, "fields": "files(id,mimeType)"}' \
| jq -e '.files'
- name: Smoketest — Gmail messages
env:
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
run: |
./bin/gws gmail users messages list \
--params '{"userId": "me", "maxResults": 1, "fields": "messages(id)"}' \
| jq -e '.messages'
- name: Smoketest — Calendar events
env:
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
run: |
./bin/gws calendar events list \
--params '{"calendarId": "primary", "maxResults": 1, "fields": "kind,items(id,status)"}' \
| jq -e '.kind'
- name: Smoketest — Slides presentation
env:
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
run: |
./bin/gws slides presentations get \
--params '{"presentationId": "1knOKD_87JWE4qsEbO4r5O91IxTER5ybBBhOJgZ1yLFI", "fields": "presentationId,slides(objectId)"}' \
| jq -e '.presentationId'
- name: Smoketest — pagination
env:
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
run: |
LINES=$(./bin/gws drive files list \
--params '{"pageSize": 1, "fields": "nextPageToken,files(id)"}' \
--page-all --page-limit 2 \
| wc -l)
if [ "$LINES" -lt 2 ]; then
echo "::error::Expected at least 2 NDJSON lines from pagination, got $LINES"
exit 1
fi
- name: Smoketest — error handling
run: |
if ./bin/gws fakeservice list 2>&1; then
echo "::error::Expected exit code 1 for unknown service"
exit 1
fi
- name: Cleanup credentials
if: always()
run: rm -f /tmp/credentials.json
+60
View File
@@ -0,0 +1,60 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: CLA
on:
check_run:
types: [completed]
permissions:
pull-requests: write
jobs:
cla-label:
name: CLA Label
if: github.event.check_run.name == 'cla/google'
runs-on: ubuntu-latest
steps:
- name: Update CLA label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
script: |
const cr = context.payload.check_run;
const passed = cr.conclusion === 'success';
for (const pr of cr.pull_requests) {
const labels = passed
? { add: 'cla: yes', remove: 'cla: no' }
: { add: 'cla: no', remove: 'cla: yes' };
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [labels.add],
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: labels.remove,
});
} catch (e) {
// Label not present — ignore
}
}
+54
View File
@@ -0,0 +1,54 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Coverage
on:
push:
branches: [main]
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
pull_request:
branches: [main]
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov
with:
tool: cargo-llvm-cov
- name: Generate code coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
files: lcov.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
+119
View File
@@ -0,0 +1,119 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Generate Skills
on:
schedule:
- cron: "0 * * * *" # Hourly — keeps skills in sync with Discovery API changes
workflow_dispatch: # Manual trigger
push:
branches-ignore:
- main # main is kept up to date by PR merges
concurrency:
group: generate-skills-${{ github.ref_name }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
generate:
name: Generate and commit skills
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# For cron/dispatch: check out main. For push: check out the branch.
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Setup sccache
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
- name: Cache cargo
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
with:
key: generate-skills-ubuntu
- name: Generate skills
run: cargo run -- generate-skills
- name: Check for changes
id: diff
run: |
if git diff --quiet skills/ docs/skills.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
# --- Cron / workflow_dispatch: open a PR against main ---
- name: Create changeset for sync PR
if: >-
steps.diff.outputs.changed == 'true' &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: |
mkdir -p .changeset
cat > .changeset/sync-skills.md << 'EOF'
---
"@googleworkspace/cli": patch
---
Sync generated skills with latest Google Discovery API specs
EOF
- name: Create or update sync PR
if: >-
steps.diff.outputs.changed == 'true' &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
branch: chore/sync-skills
title: "chore: sync skills with Discovery API"
body: |
Automated PR — the Google Discovery API specs have changed and the
generated skill files are out of date.
Created by the **Generate Skills** workflow (`generate-skills.yml`).
commit-message: "chore: regenerate skills from Discovery API"
add-paths: |
skills/
docs/skills.md
.changeset/sync-skills.md
delete-branch: true
# --- Push events (non-main branches): commit directly ---
- name: Commit and push if changed
if: >-
steps.diff.outputs.changed == 'true' &&
github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
run: |
git config user.name "googleworkspace-bot"
git config user.email "googleworkspace-bot@users.noreply.github.com"
git add skills/ docs/skills.md
git commit -m "chore: regenerate skills [skip ci]"
git push
+67
View File
@@ -0,0 +1,67 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Policy
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
policy-check:
name: Policy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Enforce AGENTS.md rules
run: |
# Check CLI crate for prohibited google-* registry crates.
# Path dependencies (e.g. google-workspace = { path = ... }) are allowed.
if grep -E "^google-[a-zA-Z0-9_-]+[[:space:]]*=" crates/google-workspace-cli/Cargo.toml | grep -v 'path[[:space:]]*='; then
echo "::error file=crates/google-workspace-cli/Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime."
exit 1
fi
echo "Policy check passed."
- name: Enforce Changeset File
if: github.event_name == 'pull_request'
run: |
git fetch origin ${{ github.base_ref }}
# Skip changeset requirement if no Rust/Cargo files changed
if ! git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -qE '\.(rs)$|^Cargo\.(toml|lock)$'; then
echo "No Rust/Cargo files changed; skipping changeset requirement."
exit 0
fi
if ! git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^.changeset/.*\.md$"; then
echo "::error::A Changeset file is required! Please run 'npx changeset' or manually create a markdown file in the .changeset directory describing your changes to automatically version and release this PR."
exit 1
fi
echo "Changeset file found!"
- name: Validate Changeset Package Name
if: github.event_name == 'pull_request'
run: |
for f in $(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^.changeset/.*\.md$"); do
if grep -q '"googleworkspace-cli"' "$f"; then
echo "::error file=$f::Wrong package name. Use '\"@googleworkspace/cli\"' not '\"googleworkspace-cli\"'."
exit 1
fi
done
echo "Changeset package names valid!"
+55
View File
@@ -0,0 +1,55 @@
name: Publish OpenClaw Skills
on:
push:
branches: [main]
paths:
- "skills/**"
- ".github/workflows/publish-skills.yml"
pull_request:
branches: [main]
paths:
- "skills/**"
- ".github/workflows/publish-skills.yml"
schedule:
- cron: "0 * * * *" # Hourly, to drip-publish past rate limits
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
publish:
# Skip fork PRs — secrets (CLAWHUB_TOKEN) are not available
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Install ClawHub CLI
run: npm i -g clawhub@0.7.0
- name: Authenticate ClawHub
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
if [ -z "$CLAWHUB_TOKEN" ]; then
echo "::error::CLAWHUB_TOKEN secret is not set"
exit 1
fi
clawhub login --token "$CLAWHUB_TOKEN"
- name: Publish skills
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
clawhub sync --root skills --all --dry-run
else
clawhub sync --root skills --all
fi
+72
View File
@@ -0,0 +1,72 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Release (Changeset)
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Install Nix
uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30
with:
github_access_token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
- uses: pnpm/action-setup@c5ba7f7862a0f64c1b1a05fbac13e0b8e86ba08c # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install
- run: |
git config --global user.name "googleworkspace-bot"
git config --global user.email "googleworkspace-bot@google.com"
- name: Create Release Pull Request or Tag
id: changesets
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1
with:
version: pnpm run version-sync
publish: pnpm run tag-release
commit: 'chore: release versions'
title: 'chore: release versions'
setupGitUser: false
env:
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
+227
View File
@@ -0,0 +1,227 @@
# Release workflow for @googleworkspace/cli
#
# Triggered by pushing a semver tag (e.g. v0.22.3).
# Builds platform binaries, creates a GitHub Release, publishes to npm and crates.io.
name: Release
permissions:
contents: write
attestations: write
id-token: write
on:
pull_request:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
env:
CARGO_TERM_COLOR: always
jobs:
plan:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
prerelease: ${{ steps.meta.outputs.prerelease }}
publishing: ${{ github.ref_type == 'tag' }}
steps:
- id: meta
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: plan
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-latest
archive: tar.gz
- target: x86_64-apple-darwin
runner: macos-latest
archive: tar.gz
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
archive: tar.gz
cross: true
- target: aarch64-unknown-linux-musl
runner: ubuntu-latest
archive: tar.gz
cross: true
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
archive: tar.gz
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
archive: tar.gz
cross: true
- target: x86_64-pc-windows-msvc
runner: windows-latest
archive: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.cross
run: cargo install cross --git https://github.com/cross-rs/cross --tag v0.2.5
- name: Build
run: |
if [ "${{ matrix.cross }}" = "true" ]; then
cross build --release --target ${{ matrix.target }} --locked
else
cargo build --release --target ${{ matrix.target }} --locked
fi
shell: bash
- name: Package (unix)
if: matrix.archive == 'tar.gz'
run: |
ARTIFACT="google-workspace-cli-${{ matrix.target }}"
mkdir -p staging
cp "target/${{ matrix.target }}/release/gws" staging/
cp LICENSE README.md CHANGELOG.md staging/
tar czf "${ARTIFACT}.tar.gz" -C staging .
shasum -a 256 "${ARTIFACT}.tar.gz" > "${ARTIFACT}.tar.gz.sha256"
shell: bash
- name: Package (windows)
if: matrix.archive == 'zip'
run: |
$ARTIFACT = "google-workspace-cli-${{ matrix.target }}"
New-Item -ItemType Directory -Path $ARTIFACT
Copy-Item "target/${{ matrix.target }}/release/gws.exe" "$ARTIFACT/"
Copy-Item LICENSE, README.md, CHANGELOG.md "$ARTIFACT/"
Compress-Archive -Path "$ARTIFACT/*" -DestinationPath "$ARTIFACT.zip"
(Get-FileHash "$ARTIFACT.zip" -Algorithm SHA256).Hash.ToLower() + " $ARTIFACT.zip" | Out-File -Encoding ascii "$ARTIFACT.zip.sha256"
shell: pwsh
- name: Attest
if: github.ref_type == 'tag'
uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3
with:
subject-path: google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: binary-${{ matrix.target }}
path: |
google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}
google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}.sha256
release:
needs: [plan, build]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: binary-*
path: artifacts/
merge-multiple: true
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PRERELEASE_FLAG=""
if [ "${{ needs.plan.outputs.prerelease }}" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
NOTES="## Installation
Download the archive for your OS and architecture from the assets below.
### Linux / macOS
Replace \`<target>\` with your platform (e.g., \`aarch64-apple-darwin\` or \`x86_64-unknown-linux-gnu\`).
\`\`\`bash
# 1. Download the archive and its checksum
curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-<target>.tar.gz
curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-<target>.tar.gz.sha256
# 2. Verify the checksum
shasum -a 256 -c google-workspace-cli-<target>.tar.gz.sha256
# 3. Extract and install
tar -xzf google-workspace-cli-<target>.tar.gz
chmod +x gws
sudo mv gws /usr/local/bin/
\`\`\`
### Windows
1. Download \`google-workspace-cli-x86_64-pc-windows-msvc.zip\` and its \`.sha256\` file
2. Verify the checksum (e.g., using PowerShell \`Get-FileHash\`)
3. Extract the archive and move \`gws.exe\` to a directory included in your system \`PATH\`.
---
"
gh release create "${{ github.ref_name }}" \
--target "${{ github.sha }}" \
--title "${{ github.ref_name }}" \
--notes "$NOTES" \
--generate-notes \
$PRERELEASE_FLAG \
artifacts/*
publish-npm:
needs: [plan, release]
runs-on: ubuntu-latest
if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: '20.x'
registry-url: 'https://wombat-dressing-room.appspot.com'
- name: Publish to npm
working-directory: npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-cargo:
needs: [plan, release]
runs-on: ubuntu-latest
if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
# Publish library crate first (CLI depends on it)
- name: Publish google-workspace
run: cargo publish --package google-workspace
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Wait for crates.io to index the library crate
- name: Wait for crates.io index
run: sleep 30
- name: Publish google-workspace-cli
run: cargo publish --package google-workspace-cli
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+36
View File
@@ -0,0 +1,36 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: 'Close Stale PRs'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 3
days-before-pr-close: 0
stale-pr-message: 'This PR has been inactive for 72 hours. Closing to keep the queue clean.'
close-pr-message: 'This PR was closed because it has been stalled for 72 hours. Feel free to magically reopen it if you want to continue working on it!'
exempt-pr-labels: 'keep-alive'
+50
View File
@@ -0,0 +1,50 @@
# Rust
/target/
**/*.rs.bk
bin/
lcov.info
.emails/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
# Build outputs
/dist/
*.tsbuildinfo
/bin/gws-native*
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Test artifacts
*.log
coverage/
.nyc_output/
download.txt
files.jsonl
# Plans (local design docs)
docs/plans/
# Generated
demo.mp4
download.html
+239
View File
@@ -0,0 +1,239 @@
# AGENTS.md
## Project Overview
`gws` is a Rust CLI tool for interacting with Google Workspace APIs. It dynamically generates its command surface at runtime by parsing Google Discovery Service JSON documents.
> [!IMPORTANT]
> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `crates/google-workspace/src/services.rs` and verify the Discovery URL pattern in `crates/google-workspace/src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs.
> [!NOTE]
> **Package Manager**: Use `pnpm` instead of `npm` for Node.js package management in this repository.
## Build & Test
> [!IMPORTANT]
> **Test Coverage**: The `codecov/patch` check requires that new or modified lines are covered by tests. When adding code, extract testable helper functions rather than embedding logic in `main`/`run` where it's hard to unit-test. Run `cargo test` locally and verify new branches are exercised.
```bash
cargo build # Build in dev mode
cargo clippy -- -D warnings # Lint check
cargo test # Run tests
```
## Changesets
Every PR must include a changeset file. Create one at `.changeset/<descriptive-name>.md`:
```markdown
---
"@googleworkspace/cli": patch
---
Brief description of the change
```
Use `patch` for fixes/chores, `minor` for new features, `major` for breaking changes. The CI policy check will fail without a changeset.
## Architecture
The CLI uses a **two-phase argument parsing** strategy:
1. Parse argv to extract the service name (e.g., `drive`)
2. Fetch the service's Discovery Document, build a dynamic `clap::Command` tree, then re-parse
### Workspace Layout
The repository is a Cargo workspace with two crates:
| Crate | Package | Purpose |
| ------------------------------ | ----------------------- | ------------------------------------------------- |
| `crates/google-workspace/` | `google-workspace` | Publishable library — core types and helpers |
| `crates/google-workspace-cli/` | `google-workspace-cli` | Binary crate — the `gws` CLI |
#### Library (`crates/google-workspace/src/`)
| File | Purpose |
| ---------------- | ---------------------------------------------------------- |
| `discovery.rs` | Serde models for Discovery Document + async fetch/cache |
| `services.rs` | Service alias → Discovery API name/version mapping |
| `error.rs` | `GwsError` enum, exit codes, JSON serialization |
| `validate.rs` | Path/URL/resource validators, `encode_path_segment()` |
| `client.rs` | HTTP client with retry logic |
#### CLI (`crates/google-workspace-cli/src/`)
| File | Purpose |
| ------------------- | ------------------------------------------------------------------------ |
| `main.rs` | Entrypoint, two-phase CLI parsing, method resolution |
| `auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC |
| `credential_store.rs` | AES-256-GCM encryption/decryption of credential files |
| `auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` |
| `commands.rs` | Recursive `clap::Command` builder from Discovery resources |
| `executor.rs` | HTTP request construction, response handling, schema validation |
| `schema.rs` | `gws schema` command — introspect API method schemas |
| `logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
| `timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API |
## Demo Videos
Demo recordings are generated with [VHS](https://github.com/charmbracelet/vhs) (`.tape` files).
```bash
vhs docs/demo.tape
```
### VHS quoting rules
- Use **double quotes** for simple strings: `Type "gws --help" Enter`
- Use **backtick quotes** when the typed text contains JSON with double quotes:
```
Type `gws drive files list --params '{"pageSize":5}'` Enter
```
`\"` escapes inside double-quoted `Type` strings are **not supported** by VHS and will cause parse errors.
### Scene art
ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears the screen and cats the file. Portrait scenes use `scene*.txt`; landscape chapters use `long-*.txt`.
## Input Validation & URL Safety
> [!IMPORTANT]
> This CLI is frequently invoked by AI/LLM agents. Always assume inputs can be adversarial — validate paths against traversal (`../../.ssh`), restrict format strings to allowlists, reject control characters, and encode user values before embedding them in URLs.
> [!NOTE]
> **Environment variables are trusted inputs.** The validation rules above apply to **CLI arguments** that may be passed by untrusted AI agents. Environment variables (e.g. `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`) are set by the user themselves — in their shell profile, `.env` file, or deployment config — and are not subject to path traversal validation. This is consistent with standard conventions like `XDG_CONFIG_HOME`, `CARGO_HOME`, etc.
### Path Safety (`crates/google-workspace/src/validate.rs`)
When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers:
| Scenario | Validator | Rejects |
| -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |
```rust
// In your argument parser:
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
crate::validate::validate_safe_output_dir(output_dir)?;
builder.output_dir(Some(output_dir.clone()));
}
```
### URL Encoding (`crates/google-workspace-cli/src/helpers/mod.rs`)
User-supplied values embedded in URL **path segments** must be percent-encoded. Use the shared helper:
```rust
// CORRECT — encodes slashes, spaces, and special characters
let url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::helpers::encode_path_segment(file_id),
);
// WRONG — raw user input in URL path
let url = format!("https://www.googleapis.com/drive/v3/files/{}", file_id);
```
For **query parameters**, use reqwest's `.query()` builder which handles encoding automatically:
```rust
// CORRECT — reqwest encodes query values
client.get(url).query(&[("q", user_query)]).send().await?;
// WRONG — manual string interpolation in query strings
let url = format!("{}?q={}", base_url, user_query);
```
### Resource Name Validation (`crates/google-workspace-cli/src/helpers/mod.rs`)
When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first:
```rust
// Validates the string does not contain path traversal segments (`..`), control characters, or URL-breaking characters like `?` and `#`.
let project = crate::validate::validate_resource_name(&project_id)?;
let url = format!("https://pubsub.googleapis.com/v1/projects/{}/topics/my-topic", project);
```
This prevents injection of query parameters, path traversal, or other malicious payloads through resource name arguments like `--project` or `--space`.
### Checklist for New Features
When adding a new helper or CLI command:
1. **File paths** → Use `validate_safe_output_dir` / `validate_safe_dir_path`
2. **Enum flags** → Constrain via clap `value_parser` or `validate_msg_format`
3. **URL path segments** → Use `encode_path_segment()`
4. **Query parameters** → Use reqwest `.query()` builder
5. **Resource names** (project IDs, space names, topic names) → Use `validate_resource_name()`
6. **Write tests** for both the happy path AND the rejection path (e.g., pass `../../.ssh` and assert `Err`)
## PR Labels
Use these labels to categorize pull requests and issues:
- `area: discovery` — Discovery document fetching, caching, parsing
- `area: http` — Request execution, URL building, response handling
- `area: docs` — README, contributing guides, documentation
- `area: tui` — Setup wizard, picker, input fields
- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods
- `area: auth` — OAuth, credentials, multi-account, ADC
- `area: skills` — AI skill generation and management
## Helper Commands (`+verb`)
Helpers are handwritten commands prefixed with `+` that provide value the schema-driven Discovery commands cannot: multi-step orchestration, format translation (e.g., Markdown → Docs JSON), or multi-API composition.
> [!IMPORTANT]
> **Do NOT add a helper that** wraps a single API call already available via Discovery, adds flags to expose data already in the API response, or re-implements Discovery parameters as custom flags. Helper flags must control orchestration logic — use `--params` and `--format`/`jq` for API parameters and output filtering.
See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers.
## Environment Variables
### Authentication
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) |
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (no default; if unset, falls back to encrypted credentials in `~/.config/gws/`) |
| `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND` | Keyring backend: `keyring` (default, uses OS keyring with file fallback) or `file` (file only, for Docker/CI/headless) |
| `GOOGLE_APPLICATION_CREDENTIALS` | Standard Google ADC path; used as fallback when no gws-specific credentials are configured |
### Configuration
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) |
### OAuth Client
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (for `gws auth login` when no `client_secret.json` is saved) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID` above) |
### Sanitization (Model Armor)
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template (overridden by `--sanitize` flag) |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
### Helpers
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_PROJECT_ID` | GCP project ID override for quota/billing and fallback for helper commands (overridden by `--project` flag) |
### Logging
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_LOG` | Log level filter for stderr output (e.g., `gws=debug`). Off by default. |
| `GOOGLE_WORKSPACE_CLI_LOG_FILE` | Directory for JSON-line log files with daily rotation. Off by default. |
All variables can also live in a `.env` file (loaded via `dotenvy`).
+861
View File
@@ -0,0 +1,861 @@
# @googleworkspace/cli
## 0.22.5
### Patch Changes
- 5d24ac2: Add cargo-audit CI workflow for automated dependency vulnerability scanning
- ecddf2e: Add cargo-deny configuration for license, advisory, and source auditing
- 503315b: Update installation instructions to prioritize GitHub Releases over npm
- 6ccbb42: fix: auto-install binary on run if missing
pnpm skips postinstall when the package is already up to date.
This ensures run.js will auto-trigger install.js if the
binary is missing, fixing the 'gws binary not found' error.
- b307856: Migrated the internal AI skills registry (personas and recipes) from YAML to TOML. This allows us to drop the unmaintained serde_yaml dependency, improving the project's supply chain security posture.
- 158f93a: Verify SHA256 checksum of downloaded binary in npm postinstall script
- b422e5d: Pin cross-rs to v0.2.5 in release workflow to prevent unpinned git HEAD builds
## 0.22.4
### Patch Changes
- 86c08cf: Remove cargo-dist; use native Node.js fetch for npm binary installer
Replaces the cargo-dist generated release pipeline and npm package with:
- A custom GitHub Actions release workflow with matrix cross-compilation
- A zero-dependency npm installer using native `fetch()` (Node 18+)
- Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package
## 0.22.3
### Patch Changes
- 674d53a: Fix `Lint Skills` CI job by installing `uv` via `astral-sh/setup-uv` before running `uvx`
- c7c42f6: fix: register script service and resolve test path validation errors
- 80bd150: feat(auth): use strict OS keychain integration on macOS and Windows
Closes #623. The CLI no longer writes a fallback `.encryption_key` text file on macOS and Windows when securely storing credentials. Instead, it strictly uses the native OS keychain (Keychain Access on macOS, Credential Manager on Windows). If an old `.encryption_key` file is found during a successful keychain login, it will be automatically deleted for security.
Linux deployments continue to use a seamless file-based fallback by default to ensure maximum compatibility with headless continuous integration (CI) runners, Docker containers, and SSH environments without desktop DBUS services.
- ec7f56b: Sync generated skills with latest Google Discovery API specs
## 0.22.2
### Patch Changes
- a52d297: Improve proxy-aware OAuth flows and clean up review feedback for auth login.
## 0.22.1
### Patch Changes
- 6a45832: Sync generated skills with latest Google Discovery API specs
## 0.22.0
### Minor Changes
- 0850c48: Add `--draft` flag to Gmail `+send`, `+reply`, `+reply-all`, and `+forward` helpers to save messages as drafts instead of sending them immediately
## 0.21.2
### Patch Changes
- c4448b9: Add crates.io publishing to release workflow
Publishes both `google-workspace` and `google-workspace-cli` to crates.io on each release. The library crate is published first (as a dependency), followed by the CLI crate.
## 0.21.1
### Patch Changes
- ea0849a: Fix version-sync script and bump CLI crate version to 0.21.0
The `version-sync.sh` script was updating the root `Cargo.toml` which no longer has a `[package]` section after the workspace refactor. Updated to target `crates/google-workspace-cli/Cargo.toml`. Also syncs the CLI crate version to 0.21.0 to match `package.json`.
## 0.21.0
### Minor Changes
- 029e5de: Extract `google-workspace` library crate for programmatic Rust API access (closes #386)
Introduces a Cargo workspace with a new `google-workspace` library crate (`crates/google-workspace/`)
that exposes the core modules for use as a Rust dependency:
- `discovery` — Discovery Document types and fetching
- `error` — Structured `GwsError` type
- `services` — Service registry and resolution
- `validate` — Input validation and URL encoding
- `client` — HTTP client with retry logic
The `gws` binary crate re-exports all library types transparently — zero behavioral changes.
## 0.20.1
### Patch Changes
- b8fd3d9: fix(client): add 10s connect timeout to prevent hangs on initial connection
- 2bfcca9: Move version from top-level SKILL.md frontmatter to metadata and track CLI version
- 2ddb46e: test(gmail): add regression tests for RFC 2822 display name quoting
- 75a7121: Sync generated skills with latest Google Discovery API specs
## 0.20.0
### Minor Changes
- e782dd7: Forward original attachments by default and preserve inline images in HTML mode.
`+forward` now includes the original message's attachments and inline images by default,
matching Gmail web behavior. Use `--no-original-attachments` to opt out.
`+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via
`multipart/related`. In plain-text mode, inline images are not included (matching Gmail web).
## 0.19.0
### Minor Changes
- a078945: Refactor all `gws auth` subcommands to use clap for argument parsing
Replace manual argument parsing in `handle_auth_command`, `handle_login`, `resolve_scopes`, and `handle_export` with structured `clap::Command` definitions. Introduces `ScopeMode` enum for type-safe scope selection and adds proper `--help` support for all auth subcommands.
### Patch Changes
- 8a749c2: feat(helpers): add --dry-run support to events helper commands
Add dry-run mode to `gws events +renew` and `gws events +subscribe` commands.
When --dry-run is specified, the commands will print what actions would be
taken without making any API calls. This allows agents to simulate requests
and learn without reaching the server.
- d679401: Fix `mask_secret` panic on multi-byte UTF-8 secrets by using char-based indexing instead of byte-offset slicing
- d341de2: Handle --help/-h in `gws auth setup` before launching the setup wizard, preventing accidental project creation when users just want usage info
- f157208: fix: use block-style YAML sequences in generated SKILL.md frontmatter
Replace flow sequences (`bins: ["gws"]`, `skills: [...]`) with block-style
sequences (`bins:\n - gws`) in all generated SKILL.md frontmatter templates.
Flow sequences are valid YAML but rejected by `strictyaml`, which the
Agent Skills reference implementation (`agentskills validate`) uses to parse
frontmatter. This caused all 93 generated skills to fail validation.
Fixes #521
- b4d5e26: Fix auth error propagation: properly propagate errors when token directory creation or permission setting fails, instead of silently ignoring them
## 0.18.1
### Patch Changes
- a87037b: Handle SIGTERM in `gws gmail +watch` and `gws events +subscribe` for clean container shutdown.
Long-running pull loops now exit gracefully on SIGTERM (in addition to Ctrl+C),
enabling clean shutdown under Kubernetes, Docker, and systemd.
## 0.18.0
### Minor Changes
- 908cf73: feat(gmail): auto-populate From header with display name from send-as settings
Fetch the user's send-as identities to set the From header with a display name in all mail helpers (+send, +reply, +reply-all, +forward), matching Gmail web client behavior. Also enriches bare `--from` emails with their configured display name.
- 6e4daaf: Gmail helpers rollup: mail-builder migration, --attach flag (upload endpoint), +read helper
- Migrate `+send`, `+reply`, `+reply-all`, and `+forward` to the `mail-builder` crate for RFC-compliant MIME construction
- Add `--from` flag to `+send` for send-as alias support
- Add `-a`/`--attach` flag to all mail helpers (`+send`, `+reply`, `+reply-all`, `+forward`) with `mime_guess2` auto-detection, 25MB size validation, and upload endpoint support (35MB API limit vs 5MB metadata-only)
- Add `+read` helper to extract message body and headers (text, HTML, or JSON output)
- Make `OriginalMessage.thread_id` optional (`Option<String>`) for draft compatibility
- RFC 2822 display name quoting is handled natively by `mail-builder`
- Introduce `UploadSource` enum in executor for type-safe upload strategies
### Patch Changes
- 1e90380: fix(gmail): remove dead `--attachment` arg from `+send`
The `+send` subcommand defined a duplicate `"attachment"` arg alongside the
`"attach"` arg already provided by `common_mail_args`. Since `parse_attachments`
reads `"attach"`, the `--attachment` flag was silently ignored. Removed the
dead duplicate.
- 908cf73: fix(gmail): handle reply-all to own message correctly
Reply-all to a message you sent no longer errors with "No To recipient remains." The original To recipients are now used as reply targets, matching Gmail web client behavior.
- 2e909ae: Consolidate terminal sanitization, coloring, and output helpers into a new `output.rs` module. Fixes raw ANSI escape codes in `watch.rs` that bypassed `NO_COLOR` and TTY detection, upgrades `sanitize_for_terminal` to also strip dangerous Unicode characters (bidi overrides, zero-width spaces, directional isolates), and sanitizes previously raw API error body and user query outputs.
## 0.17.0
### Minor Changes
- 1b0a21f: feat: support google meet video conferencing in calendar +insert
### Patch Changes
- 811fe7b: Fix critical security vulnerability (TOCTOU/Symlink race) in atomic file writes.
The atomic_write and atomic_write_async utilities now use:
- Randomized temporary filenames to prevent predictability.
- O_EXCL creation flags to prevent following pre-existing symlinks.
- Strict 0600 permissions from the moment of file creation on Unix systems.
- Redundant post-write permission calls have been removed to close race windows.
- b241a5b: fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths
- 6f92e5b: Stderr/output hygiene rollup: route diagnostics to stderr, add colored error labels, propagate auth errors.
- **triage.rs**: "No messages found" sent to stderr so stdout stays valid JSON for pipes
- **modelarmor.rs**: response body printed only on success; error message now includes body for diagnostics
- **error.rs**: colored `error[variant]:` labels on stderr (respects `NO_COLOR` env var), `hint:` prefix for accessNotConfigured guidance
- **calendar, chat, docs, drive, script, sheets**: auth failures now propagate as `GwsError::Auth` instead of silently proceeding unauthenticated (dry-run still works without auth)
- 398e80c: Sync generated skills with latest Google Discovery API specs
- 8458104: Extend input validation to reject dangerous Unicode characters (zero-width chars, bidi overrides, Unicode line/paragraph separators) that were not caught by the previous ASCII-range check
## 0.16.0
### Minor Changes
- 47afe5f: Use Google account timezone instead of machine-local time for day-boundary calculations in calendar and workflow helpers. Adds `--timezone` flag to `+agenda` for explicit override. Timezone is fetched from Calendar Settings API and cached for 24 hours.
### Patch Changes
- c61b9cb: fix(gmail): RFC 2047 encode non-ASCII display names in To/From/Cc/Bcc headers
Fixes mojibake when sending emails to recipients with non-ASCII display names (e.g. Japanese, Spanish accented characters). The new `encode_address_header()` function parses mailbox lists, encodes only the display-name portion via RFC 2047 Base64, and leaves email addresses untouched.
## 0.15.0
### Minor Changes
- 6f3e090: Add opt-in structured HTTP request logging via `tracing`
New environment variables:
- `GOOGLE_WORKSPACE_CLI_LOG`: stderr log filter (e.g., `gws=debug`)
- `GOOGLE_WORKSPACE_CLI_LOG_FILE`: directory for JSON log files with daily rotation
Logging is completely silent by default (zero overhead). Only PII-free metadata is logged: API method ID, HTTP method, status code, latency, and content-type.
## 0.14.0
### Minor Changes
- dc561e0: Add `--upload-content-type` flag and smart MIME inference for multipart uploads
Previously, multipart uploads used the metadata `mimeType` field for both the Drive
metadata and the media part's `Content-Type` header. This made it impossible to upload
a file in one format (e.g. Markdown) and have Drive convert it to another (e.g. Google Docs),
because the media `Content-Type` and the target `mimeType` must differ for import conversions.
The new `--upload-content-type` flag allows setting the media `Content-Type` explicitly.
When omitted, the media type is now inferred from the file extension before falling back
to the metadata `mimeType`. This matches Google Drive's model where metadata `mimeType`
is the _target_ type (what the file should become) while the media `Content-Type` is the
_source_ type (what the bytes are).
This means import conversions now work automatically:
```bash
# Extension inference detects text/markdown → conversion just works
gws drive files create \
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
--upload notes.md
# Explicit flag still available as an override
gws drive files create \
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
--upload notes.md \
--upload-content-type text/markdown
```
### Patch Changes
- 945ac91: Stream multipart uploads to avoid OOM on large files. File content is now streamed in chunks via `ReaderStream` instead of being read entirely into memory, reducing memory usage from O(file_size) to O(64 KB).
## 0.13.3
### Patch Changes
- 8ef27a2: fix(calendar): use local timezone for agenda day boundaries instead of UTC
- 4d7b420: Fix `+append --json-values` flattening multi-row arrays into a single row by preserving the `Vec<Vec<String>>` row structure through to the API request body
- bb94016: fix(security): validate space name in chat +send to prevent path traversal
- 4b827cd: chore: fix maintainer email typo in flake.nix and harden coverage.sh
- 44767ed: Map People service to `contacts` and `directory` scope prefixes so `gws auth login -s people` includes the required OAuth scopes
- 8fce003: fix(docs): correct flag names in recipes (--spreadsheet-id, --attendees, --duration)
- 21b1840: Expose `repeated: true` in `gws schema` output and expand JSON arrays into repeated query parameters for `repeated` fields
- 1346d47: Sync generated skills with latest Google Discovery API specs
- 957b999: test(gmail): add unit tests for +triage argument parsing and format selection
## 0.13.2
### Patch Changes
- 3dcf818: Refresh OAuth access tokens for long-running Gmail watch and Workspace Events subscribe helpers before each Pub/Sub and Gmail request.
- 86ea6de: Validate `--subscription` resource name in `gmail +watch` and deduplicate `PUBSUB_API_BASE` constant.
## 0.13.1
### Patch Changes
- 510024f: Centralize token cache filenames as constants and support ServiceAccount credentials at the default plaintext path
- 510024f: Auto-recover from stale encrypted credentials after upgrade: remove undecryptable `credentials.enc` and fall through to other credential sources (plaintext, ADC) instead of hard-erroring. Also sync encryption key file backup when keyring has key but file is missing.
- e104106: Add shell tips section to gws-shared skill warning about zsh `!` history expansion, and replace single quotes with double quotes around sheet ranges containing `!` in recipes and skill examples
## 0.13.0
### Minor Changes
- 9d937af: Add `--html` flag to `+send`, `+reply`, `+reply-all`, and `+forward` for HTML email composition.
### Patch Changes
- 2df32ee: Document helper commands (`+` prefix) in README
Adds a "Helper Commands" section to the Advanced Usage chapter explaining
the `+` prefix convention, listing all 24 helper commands across 10 services
with descriptions and usage examples.
## 0.12.0
### Minor Changes
- 247e27a: Add structured exit codes for scriptable error handling
`gws` now exits with a type-specific code instead of always using `1`:
| Code | Meaning |
| ---- | --------------------------------------------------------------- |
| `0` | Success |
| `1` | API error — Google returned a 4xx/5xx response |
| `2` | Auth error — credentials missing, expired, or invalid |
| `3` | Validation error — bad arguments, unknown service, invalid flag |
| `4` | Discovery error — could not fetch the API schema document |
| `5` | Internal error — unexpected failure |
Exit codes are documented in `gws --help` and in the README.
### Patch Changes
- 087066f: Fix `gws auth login` encrypted credential persistence by enabling native keyring backends for the `keyring` crate on supported desktop platforms instead of silently falling back to the in-memory mock store.
## 0.11.1
### Patch Changes
- adbca87: Fix `--format csv` for array-of-arrays responses (e.g. Sheets values API)
## 0.11.0
### Minor Changes
- 4d4b09f: Add `--cc` and `--bcc` flags to `+send`, `--to` and `--bcc` to `+reply` and `+reply-all`, and `--bcc` to `+forward`.
## 0.10.0
### Minor Changes
- 8d89325: Add `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND` env var for explicit keyring backend selection (`keyring` or `file`). Fixes credential key loss in Docker/keyring-less environments by never deleting `.encryption_key` and always persisting it as a fallback.
### Patch Changes
- 06aa698: fix(auth): dynamically fetch scopes from Discovery docs when `-s` specifies services not in static scope lists
- 06aa698: fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes
- 5e7d120: Bring `+forward` behavior in line with Gmail's web UI: keep the forward in the sender's original thread, add a blank line between the forwarded message metadata and body, and remove the spurious closing delimiter.
- 2782cf1: Fix gmail +triage 403 error by using gmail.readonly scope instead of gmail.modify to avoid conflict with gmail.metadata scope that does not support the q parameter
## 0.9.1
### Patch Changes
- 5872dbe: Stop persisting encryption key to `.encryption_key` file when OS keyring is available. Existing file-based keys are migrated into the keyring and the file is removed on next CLI invocation.
## 0.9.0
### Minor Changes
- 7d15365: feat(gmail): add +reply, +reply-all, and +forward helpers
Adds three new Gmail helper commands:
- `+reply` -- reply to a message with automatic threading
- `+reply-all` -- reply to all recipients with --remove/--cc support
- `+forward` -- forward a message to new recipients
### Patch Changes
- 08716f8: Fix garbled non-ASCII email subjects in `gmail +send` by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers.
- f083eb9: Improve `gws auth setup` project creation failures in step 3:
- Detect Google Cloud Terms of Service precondition failures and show actionable guidance (`gcloud auth list`, account verification, Console ToS URL).
- Detect invalid project ID format / already-in-use errors and show clearer guidance.
- In interactive setup, keep the wizard open and re-prompt for a new project ID instead of exiting immediately on create failures.
- 789e7f1: Switch reqwest TLS from bundled Mozilla roots to native OS certificate store
This allows the CLI to trust custom or corporate CA certificates installed
in the system trust store, fixing TLS errors in enterprise environments.
## 0.8.1
### Patch Changes
- 4d41e52: Prioritize local project configuration and `GOOGLE_WORKSPACE_PROJECT_ID` over global Application Default Credentials (ADC) for quota attribution. This fixes 403 errors when the Drive API is disabled in a global gcloud project but enabled in the project configured for gws.
## 0.8.0
### Minor Changes
- dd3fc90: Remove `mcp` command
## 0.7.0
### Minor Changes
- e1505af: Remove multi-account, domain-wide delegation, and impersonation support. Removes `gws auth list`, `gws auth default`, `--account` flag, `GOOGLE_WORKSPACE_CLI_ACCOUNT` and `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env vars.
### Patch Changes
- 54b3b31: Move x-goog-user-project header from default client headers to API request builder, fixing Discovery Document fetches failing with 403 when the quota project lacks certain APIs enabled
## 0.6.3
### Patch Changes
- 322529d: Document all environment variables and enable GOOGLE_WORKSPACE_CLI_CONFIG_DIR in release builds
- 2173a92: Send x-goog-user-project header when using ADC with a quota_project_id
- 1f47420: fix: extract CLA label job into dedicated workflow to prevent feedback loop
The Automation workflow's `check_run: [completed]` trigger caused a feedback
loop — every workflow completion fired a check_run event, re-triggering
Automation, which produced another check_run event, and so on. Moving the
CLA label job to its own `cla.yml` workflow eliminates the trigger from
Automation entirely.
- 132c3b1: fix: warn on credential file permission failures instead of ignoring
Replaced silent `let _ =` on `set_permissions` calls in `save_encrypted`
with `eprintln!` warnings so users are aware if their credential files
end up with insecure permissions. Also log keyring access failures
instead of silently falling through to file storage.
- a2cc523: Add `x86_64-unknown-linux-musl` build target for Linux musl/static binary support
- c86b964: Fix multi-account selection: MCP server now respects `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var (#221), and `--account` flag before service name no longer causes parse errors (#181)
- ff53538: Fix scope selection to use first (broadest) scope instead of all method scopes, preventing gmail.metadata restrictions from blocking query parameters
- c80eb52: Replace strip_suffix(".readonly").unwrap() with unwrap_or fallback
Two call sites used `.strip_suffix(".readonly").unwrap()` which would
panic if a scope URL marked as `is_readonly` didn't actually end with
".readonly". While the current data makes this unlikely, using
`unwrap_or` is a defensive improvement that prevents potential panics
from inconsistent discovery data.
- 9a780d7: Log token cache decryption/parse errors instead of silently swallowing
Previously, `load_from_disk` used four nested `if let Ok` blocks that
silently returned an empty map on any failure. When the encryption key
changed or the cache was corrupted, tokens silently stopped loading and
users were forced to re-authenticate with no explanation.
Now logs specific warnings to stderr for decryption failures, invalid
UTF-8, and JSON parse errors, with a hint to re-authenticate.
- 6daf90d: Fix MCP tool schemas to conditionally include `body`, `upload`, and `page_all` properties only when the underlying Discovery Document method supports them. `body` is included only when a request body is defined, `upload` only when `supportsMediaUpload` is true, and `page_all` only when the method has a `pageToken` parameter. Also drops empty `body: {}` objects that LLMs commonly send on GET methods, preventing 400 errors from Google APIs.
## 0.6.2
### Patch Changes
- 28fa25a: Clean up nits from PR #175 auth fix
- Update stale docstring on `resolve_account` to match new fallthrough behavior
- Add breadcrumb comment on string-based error matching in `main.rs`
- Move identity scope injection before authenticator build for readability
## 0.6.1
### Patch Changes
- 88cb65c: chore: add automation workflow for auto-fmt, CLA labeling, and file-based PR triage
- a926e3f: Fix auth failures when accounts.json registry is missing
Three related bugs caused all API calls to fail with "Access denied. No credentials provided" even after a successful `gws auth login`:
1. `resolve_account()` rejected valid `credentials.enc` as "legacy" when `accounts.json` was absent, instead of using them.
2. `main.rs` silently swallowed all auth errors, masking real failures behind a generic message.
3. `auth login` didn't include `openid`/`email` scopes, so `fetch_userinfo_email()` couldn't identify the user, causing credentials to be saved without an `accounts.json` entry.
- cb1f988: Add Content-Length: 0 header for POST/PUT/PATCH requests with no body to fix HTTP 411 errors
- 3d59b2e: fix: isolate flaky auth tests from host ADC credentials
## 0.6.0
### Minor Changes
- b38b760: Add Application Default Credentials (ADC) support.
`gws` now discovers ADC as a fourth credential source, after the encrypted
and plaintext credential files. The lookup order is:
1. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
2. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var
3. Encrypted credentials (`~/.config/gws/credentials.enc`)
4. Plaintext credentials (`~/.config/gws/credentials.json`)
5. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` env var (hard error if file missing), then
`~/.config/gcloud/application_default_credentials.json` (silent if absent)
This means `gcloud auth application-default login --client-id-file=client_secret.json`
is now a fully supported auth flow — no need to run `gws auth login` separately.
Both `authorized_user` and `service_account` ADC formats are supported.
## 0.5.0
### Minor Changes
- 9cf6e0e: Add `--tool-mode compact|full` flag to `gws mcp`. Compact mode exposes one tool per service plus a `gws_discover` meta-tool, reducing context window usage from 200-400 tools to ~26.
### Patch Changes
- 0a16d0b: Add `-s`/`--services` flag to `gws auth login` to filter the scope picker
by service name (e.g. `-s drive,gmail,sheets`). Also expands the workspace
admin scope blocklist to include `chat.admin.*` and `classroom.*` patterns.
- 5205467: fix(setup): drain stale keypresses between TUI screen transitions
## 0.4.4
### Patch Changes
- e1e08eb: Fix highlight color on light terminal themes by using reverse video instead of a dark-gray background
## 0.4.3
### Patch Changes
- fc6bc95: Exclude Workspace-admin-only scopes from the "Recommended" scope preset.
Scopes that require Google Workspace domain-admin access (`apps.*`,
`cloud-identity.*`, `ediscovery`, `directory.readonly`, `groups`) now return
`400 invalid_scope` when used by personal `@gmail.com` accounts. These scopes
are no longer included in the "Recommended" template, preventing login failures
for non-Workspace users.
Workspace admins can still select these scopes manually via the "Full Access"
template or by picking them individually in the scope picker.
Adds a new `is_workspace_admin_scope()` helper (mirroring the existing
`is_app_only_scope()`) that centralises this detection logic.
- 2aa6084: docs: Comprehensive README overhaul addressing user feedback.
Added a Prerequisites section prior to the Quick Start to highlight the optional `gcloud` dependency.
Expanded the Authentication section with a decision matrix to help users choose the correct authentication path.
Added prominent warnings about OAuth "testing mode" limitations (the 25-scope cap) and the strict requirement to explicitly add the authorizing account as a "Test user" (#130).
Added a dedicated Troubleshooting section detailing fixes for common OAuth consent errors, "Access blocked" issues, and `redirect_uri_mismatch` failures.
Included shell escaping examples for Google Sheets A1 notation (`!`).
Clarified the `npm` installation rationale and added explicit links to pre-built native binaries on GitHub Releases.
## 0.4.2
### Patch Changes
- d3e90e4: fix: use ~/.config/gws on all platforms for consistent config path
Previously used `dirs::config_dir()` which resolves to different paths per OS
(e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows),
contradicting the documented ~/.config/gws/ path. Now uses ~/.config/gws/
everywhere with a fallback to the legacy OS-specific path for existing installs.
## 0.4.1
### Patch Changes
- dbda001: Add "Enter project ID manually" option to project picker in `gws auth setup`.
Users with large numbers of GCP projects often hit the 10-second listing timeout.
The picker now includes a "⌨ Enter project ID manually" item so users can type a
known project ID directly without waiting for `gcloud projects list` to complete.
## 0.4.0
### Minor Changes
- 87e4bb1: Add Linux ARM64 build targets (aarch64-unknown-linux-gnu and aarch64-unknown-linux-musl) to cargo-dist, enabling prebuilt binaries for ARM64 Linux users via npm, the shell installer, and GitHub Releases.
- d1825f9: ### Multi-Account Support
Add support for managing multiple Google accounts with per-account credential storage.
**New features:**
- `--account EMAIL` global flag available on every command
- `GOOGLE_WORKSPACE_CLI_ACCOUNT` environment variable as fallback
- `gws auth login --account EMAIL` — associates credentials with a specific account
- `gws auth list` — lists all registered accounts
- `gws auth default EMAIL` — sets the default account
- `gws auth logout --account EMAIL` — removes a specific account
- `login_hint` in OAuth URL for automatic account pre-selection in browser
- Email validation via Google userinfo endpoint after OAuth flow
**Breaking change:** Existing users must run `gws auth login` again after upgrading. The credential storage format has changed from a single `credentials.enc` to per-account files (`credentials.<b64-email>.enc`) with an `accounts.json` registry.
### Patch Changes
- a6994ad: Filter out `apps.alerts` scopes from user OAuth login flow since they require service account with domain-wide delegation
- 1ad4f34: fix: replace unwrap() calls with proper error handling in MCP server
Replaced four `unwrap()` calls in `mcp_server.rs` that could panic the MCP
server process with graceful error handling. Also added a warning log when
authentication silently falls back to unauthenticated mode.
- a1be14f: fix: drain stdout pipe to prevent project listing timeout during auth setup
Fixed `gws auth setup` timing out at step 3 (GCP project selection) for users
with many projects. The `gcloud projects list` stdout pipe was only read after
the child process exited, causing a deadlock when output exceeded the OS pipe
buffer (~64 KB). Stdout is now drained in a background thread to prevent the
pipe from filling up.
- 364542b: fix: reject DEL character (0x7F) in input validation
The `reject_control_chars` helper rejected bytes 0x000x1F but allowed
the DEL character (0x7F), which is also an ASCII control character. This
could allow malformed input from LLM agents to bypass validation.
- 75cec1b: Fix URL template expansion so media upload endpoints substitute path parameters and avoid iterative replacement side effects.
- ed409e3: Harden URL and path construction across helper modules (gmail/watch, modelarmor, discovery)
- 263a8e5: fix: use gcloud.cmd on Windows and show platform-correct config paths
On Windows, gcloud is installed as `gcloud.cmd` which Rust's `Command`
cannot find without the extension. Also replaced hardcoded `~/.config/gws/`
in error messages with the actual platform-resolved path.
## 0.3.5
### Patch Changes
- 4bca693: fix: credential masking panic and silent token write errors
Fixed `gws auth export` masking which panicked on short strings and showed
the entire secret instead of masking it. Also fixed silent token cache write
failures in `save_to_disk` that returned `Ok(())` even when the write failed.
- f84ce37: Fix URL template path expansion to safely encode path parameters, including
Sheets `range` values with Unicode and reserved characters. `{var}` expansions
now encode as a path segment, `{+var}` preserves slashes while encoding each
segment, and invalid path parameter/template mismatches fail fast.
- eb0347a: fix: correct author email typo in package.json
- 70d0cdd: Fix Slides presentations.get failure caused by flatPath placeholder mismatch
When a Discovery Document's `flatPath` uses placeholder names that don't match
the method's parameter names (e.g., `{presentationsId}` vs `presentationId`),
`build_url` now falls back to the `path` field which uses RFC 6570 operators
that resolve correctly.
Fixes #118
- 37ab483: Add flake.nix for nix & NixOS installs
- 1991d53: Add prominent disclaimer that this is not an officially supported Google product to README, --help, and --version output
## 0.3.4
### Patch Changes
- 704928b: fix(setup): enable APIs individually and surface gcloud errors
Previously `gws auth setup` used a single batch `gcloud services enable` call
for all Workspace APIs. If any one API failed, the entire batch was marked as
failed and stderr was silently discarded. APIs are now enabled individually and
in parallel, with error messages surfaced to the user.
## 0.3.3
### Patch Changes
- 92e66a3: Add `gws version` as a bare subcommand alongside `gws --version` and `gws -V`
## 0.3.2
### Patch Changes
- 8fadbd6: Smarter truncation of method and resource descriptions from discovery docs. Descriptions now truncate at sentence boundaries when possible, fall back to word boundaries with an ellipsis, and strip markdown links to reclaim character budget. Fixes #64.
## 0.3.1
### Patch Changes
- b3669e0: Add hourly cron to generate-skills workflow to auto-sync skills with upstream Google Discovery API changes via PR
- e8d533e: Add workflow to publish OpenClaw skills to ClawHub
- 3b38c8d: Sync generated skills with latest Google Discovery API specs
## 0.3.0
### Minor Changes
- 670267f: feat: add `gws mcp` Model Context Protocol server
Adds a new `gws mcp` subcommand that starts an MCP server over stdio,
exposing Google Workspace APIs as structured tools to any MCP-compatible
client (Claude Desktop, Gemini CLI, VS Code, etc.).
### Patch Changes
- 8c1042a: Fix x-goog-api-client header format to use `gl-rust/gws-<version>`
- 3de9762: Fix docs: `gws setup` → `gws auth setup` (fixes #56, #57)
## 0.2.2
### Patch Changes
- f281797: docs(auth): add manual Google Cloud OAuth client setup and browser-assisted login guidance
Adds step-by-step guidance for creating a Desktop OAuth client in Google Cloud Console,
where to place `client_secret.json`, and how humans/agents can complete browser consent
(including unverified app and scope-selection prompts).
- ee2e216: Narrow default OAuth scopes to avoid `Error 403: restricted_client` on unverified apps and add a `--full` flag for broader access (fixes #25). Replace the cryptic non-interactive setup error with actionable step-by-step OAuth console instructions (fixes #24).
- de2787e: feat(error): detect disabled APIs and guide users to enable them
When the Google API returns a 403 `accessNotConfigured` error (i.e., the
required API has not been enabled for the GCP project), `gws` now:
- Extracts the GCP Console enable URL from the error message body.
- Prints the original error JSON to stdout (machine-readable, unchanged shape
except for an optional new `enable_url` field added to the error object).
- Prints a human-readable hint with the direct enable URL to stderr, along
with instructions to retry after enabling.
This prevents a dead-end experience where users see a raw 403 JSON blob
with no guidance. The JSON output is backward-compatible; only an optional
`enable_url` field is added when the URL is parseable from the message.
Fixes #31
- 9935dde: ci: auto-generate and commit skills on PR branch pushes
- 4b868c7: docs: add community guidance to gws-shared skill and gws --help output
Encourages agents and users to star the repository and directs bug reports
and feature requests to GitHub Issues, with guidance to check for existing
issues before opening new ones.
- 0603bce: fix: atomic credential file writes to prevent corruption on crash or Ctrl-C
- 666f9a8: fix(auth): support --help / -h flag on auth subcommand
- bcd2401: fix: flatten nested objects in table output and fix multi-byte char truncation panic
- ee35e4a: fix: warn to stderr when unknown --format value is provided
- e094b02: fix: YAML block scalar for strings with `#`/`:`, and repeated CSV/table headers with `--page-all`
**Bug 1 — YAML output: `drive#file` rendered as block scalar**
Strings containing `#` or `:` (e.g. `drive#file`, `https://…`) were
incorrectly emitted as YAML block scalars (`|`), producing output like:
```yaml
kind: |
drive#file
```
Block scalars add an implicit trailing newline which changes the string
value and produces invalid-looking output. The fix restricts block
scalar to strings that genuinely contain newlines; all other strings
are double-quoted, which is safe for any character sequence.
**Bug 2 — `--page-all` with `--format csv` / `--format table` repeats headers**
When paginating with `--page-all`, each page printed its own header row,
making the combined output unusable for downstream processing:
```
id,kind,name ← page 1 header
1,drive#file,foo.txt
id,kind,name ← page 2 header (unexpected!)
2,drive#file,bar.txt
```
Column headers (and the table separator line) are now emitted only for
the first page; continuation pages contain data rows only.
- 173d155: fix: add YAML document separators (---) when paginating with --page-all --format yaml
- 214fc18: ci: skip smoketest on fork pull requests
## 0.2.1
### Patch Changes
- 6ae7427: fix(auth): stabilize encrypted credential key fallback across sessions
When the OS keyring returned `NoEntry`, the previous code could generate
a fresh random key on each process invocation instead of reusing one.
This caused `credentials.enc` written by `gws auth login` to be
unreadable by subsequent commands.
Changes:
- Always prefer an existing `.encryption_key` file before generating a new key
- When generating a new key, persist it to `.encryption_key` as a stable fallback
- Best-effort write new keys into the keyring as well
- Fix `OnceLock` race: return the already-cached key if `set` loses a race
Fixes #27
## 0.2.0
### Minor Changes
- b0d0b95: Add workflow helpers, personas, and 50 consumer-focused recipes
- Add `gws workflow` subcommand with 5 built-in helpers: `+standup-report`, `+meeting-prep`, `+email-to-task`, `+weekly-digest`, `+file-announce`
- Add 10 agent personas (exec-assistant, project-manager, sales-ops, etc.) with curated skill sets
- Add `docs/skills.md` skills index and `registry/recipes.yaml` with 50 multi-step recipes for Gmail, Drive, Docs, Calendar, and Sheets
- Update README with skills index link and accurate skill count
- Fix lefthook pre-commit to run fmt and clippy sequentially
### Patch Changes
- 90adcb4: fix: percent-encode path parameters to prevent path traversal
- e71ce29: Fix Gemini extension installation issue by removing redundant authentication settings and update the documentation.
- 90adcb4: fix: harden input validation for AI/LLM callers
- Add `src/validate.rs` with `validate_safe_output_dir`, `validate_msg_format`, and `validate_safe_dir_path` helpers
- Validate `--output-dir` against path traversal in `gmail +watch` and `events +subscribe`
- Validate `--msg-format` against allowlist (full, metadata, minimal, raw) in `gmail +watch`
- Validate `--dir` against path traversal in `script +push`
- Add clap `value_parser` constraint for `--msg-format`
- Document input validation patterns in `AGENTS.md`
- 90adcb4: Security: Harden validate_resource_name and fix Gmail watch path traversal
- 90adcb4: Replace manual `urlencoded()` with reqwest `.query()` builder for safer URL encoding
- c11d3c4: Added test coverage for `EncryptedTokenStorage::new` initialization.
- 7664357: Add test for missing error path in load_client_config
- 90adcb4: fix: add shared URL safety helpers for path params (`encode_path_segment`, `validate_resource_name`)
- 90adcb4: fix: warn on stderr when API calls fail silently
## 0.1.5
### Patch Changes
- d29f41e: Fix README typography and spacing
## 0.1.4
### Patch Changes
- adb2cfa: Fix OAuth login failing with "no refresh token" error by decrypting the token cache before parsing and supporting the HashMap token format used by EncryptedTokenStorage
- d990dcc: Improve README branding by making the hero banner full-width.
## 0.1.3
### Patch Changes
- c714f4b: Fix npm package name to publish as @googleworkspace/cli instead of gws
## 0.1.2
### Patch Changes
- 3cd4d52: Fix release pipeline to sync Cargo.toml version with changesets and create git tags for private packages
## 0.1.1
### Patch Changes
- a0ad089: Speed up CI builds with Swatinem/rust-cache, sccache, and build artifact reuse for smoketests
- 30d929b: Optimize demo GIF and improve README
+1
View File
@@ -0,0 +1 @@
When contributing to this repository, you must strictly follow all guidelines outlined in the AGENTS.md file.
+74
View File
@@ -0,0 +1,74 @@
# Google Workspace CLI (`gws`) Context
The `gws` CLI provides dynamic access to Google Workspace APIs (Drive, Gmail, Calendar, Sheets, Admin, etc.) by parsing Discovery Documents at runtime.
## Rules of Engagement for Agents
* **Schema Discovery:** *If you don't know the exact JSON payload structure, run `gws schema <resource>.<method>` first to inspect the schema before executing.*
* **Context Window Protection:** *Workspace APIs (like Drive and Gmail) return massive JSON blobs. ALWAYS use field masks when listing or getting resources by appending `--params '{"fields": "id,name"}'` to avoid overwhelming your context window.*
* **Dry-Run Safety:** *Always use the `--dry-run` flag for mutating operations (create, update, delete) to validate your JSON payload before actual execution.*
## Core Syntax
```bash
gws <service> <resource> [sub-resource] <method> [flags]
```
Use `--help` to get help on the available commands.
```bash
gws --help
gws <service> --help
gws <service> <resource> --help
gws <service> <resource> <method> --help
```
### Key Flags
- `--params '<JSON>'`: URL/query parameters (e.g., `id`, `q`, `pageSize`).
- `--json '<JSON>'`: Request body for POST/PUT/PATCH methods.
- `--page-all`: Auto-paginates results and outputs NDJSON (one JSON object per line).
- `--fields '<MASK>'`: Limits the response fields (critical for AI context window efficiency).
- `--upload <PATH>`: Files for multipart uploads (e.g., `drive files create`).
- `--output <PATH>`: Destination for binary downloads (e.g., `drive files get`).
- `--sanitize <TEMPLATE>`: Sanitizes output using Google Cloud Model Armor.
## Usage Patterns
### 1. Reading Data (GET/LIST)
Always use `--fields` to minimize tokens.
```bash
# List Drive files (efficient)
gws drive files list --params '{"q": "name contains \"Report\"", "pageSize": 10}' --fields "files(id,name,mimeType)"
# Get Gmail message details
gws gmail users messages get --params '{"userId": "me", "id": "MSG_123"}'
```
### 2. Writing Data (POST/PUT/PATCH)
Use `--json` for the request body.
```bash
# Send Email
gws gmail users messages send --params '{"userId": "me"}' --json '{"raw": "BASE64..."}'
# Create Spreadsheet
gws sheets spreadsheets create --json '{"properties": {"title": "Q4 Budget"}}'
```
### 3. Pagination (NDJSON)
Use `--page-all` for listing large collections. The output is Newline Delimited JSON.
```bash
# Stream all users
gws admin users list --params '{"domain": "example.com"}' --page-all
```
### 4. Schema Introspection
If unsure about parameters or body structure, check the schema:
```bash
gws schema drive.files.list
gws schema sheets.spreadsheets.create
```
Generated
+3948
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[workspace]
members = ["crates/google-workspace-cli", "crates/google-workspace"]
resolver = "2"
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+510
View File
@@ -0,0 +1,510 @@
<h1 align="center">gws</h1>
**One CLI for all of Google Workspace — built for humans and AI agents.**<br>
Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JSON output. 40+ agent skills included.
> [!NOTE]
> This is **not** an officially supported Google product.
<p>
<a href="https://www.npmjs.com/package/@googleworkspace/cli"><img src="https://img.shields.io/npm/v/@googleworkspace/cli" alt="npm version"></a>
<a href="https://github.com/googleworkspace/cli/blob/main/LICENSE"><img src="https://img.shields.io/github/license/googleworkspace/cli" alt="license"></a>
<a href="https://github.com/googleworkspace/cli/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/googleworkspace/cli/ci.yml?branch=main&label=CI" alt="CI status"></a>
<a href="https://www.npmjs.com/package/@googleworkspace/cli"><img src="https://img.shields.io/npm/unpacked-size/@googleworkspace/cli" alt="install size"></a>
</p>
<br>
⬇️ **[Download the latest release for your OS](https://github.com/googleworkspace/cli/releases)**
`gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically.
> [!IMPORTANT]
> This project is under active development. Expect breaking changes as we march toward v1.0.
## Contents
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Why gws?](#why-gws)
- [Authentication](#authentication)
- [AI Agent Skills](#ai-agent-skills)
- [Advanced Usage](#advanced-usage)
- [Environment Variables](#environment-variables)
- [Exit Codes](#exit-codes)
- [Architecture](#architecture)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
## Prerequisites
- **Node.js 18+** — for `npm install` (or download a pre-built binary from [GitHub Releases](https://github.com/googleworkspace/cli/releases))
- **A Google Cloud project** — required for OAuth credentials. You can create one via the [Google Cloud Console](https://console.cloud.google.com/) or with the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) or with the `gws auth setup` command.
- **A Google account** with access to Google Workspace
## Installation
The recommended way to install `gws` is to download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. Extract the archive and place the `gws` binary in your `$PATH`.
For convenience, you can also use `npm` to automate downloading the appropriate binary from GitHub Releases:
```bash
npm install -g @googleworkspace/cli
```
Or build from source:
```bash
cargo install --git https://github.com/googleworkspace/cli --locked
```
A Nix flake is also available at `github:googleworkspace/cli`
```bash
nix run github:googleworkspace/cli
```
On macOS and Linux, you can also install via [Homebrew](https://brew.sh/):
```bash
brew install googleworkspace-cli
```
## Quick Start
```bash
gws auth setup # walks you through Google Cloud project config
gws auth login # subsequent OAuth login
gws drive files list --params '{"pageSize": 5}'
```
## Why gws?
**For humans** — stop writing `curl` calls against REST docs. `gws` gives you `--help` on every resource, `--dry-run` to preview requests, and autopagination.
**For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage Workspace without custom tooling.
```bash
# List the 10 most recent files
gws drive files list --params '{"pageSize": 10}'
# Create a spreadsheet
gws sheets spreadsheets create --json '{"properties": {"title": "Q1 Budget"}}'
# Send a Chat message
gws chat spaces messages create \
--params '{"parent": "spaces/xyz"}' \
--json '{"text": "Deploy complete."}' \
--dry-run
# Introspect any method's request/response schema
gws schema drive.files.list
# Stream paginated results as NDJSON
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
```
## Authentication
The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server.
### Which setup should I use?
| I have… | Use |
|---|---|
| `gcloud` installed and authenticated | [`gws auth setup`](#interactive-local-desktop) (fastest) |
| A GCP project but no `gcloud` | [Manual OAuth setup](#manual-oauth-setup-google-cloud-console) |
| An existing OAuth access token | [`GOOGLE_WORKSPACE_CLI_TOKEN`](#pre-obtained-access-token) |
| Existing Credentials | [`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`](#service-account-server-to-server) |
### Interactive (local desktop)
Credentials are encrypted at rest (AES-256-GCM) with the key stored in your OS keyring (or `~/.config/gws/.encryption_key` when `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file`).
```bash
gws auth setup # one-time: creates a Cloud project, enables APIs, logs you in
gws auth login # subsequent scope selection and login
```
> `gws auth setup` requires the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install). If you don't have `gcloud`, use the [manual setup](#manual-oauth-setup-google-cloud-console) below instead.
> [!WARNING]
> **Scope limits in testing mode:** If your OAuth app is unverified (testing mode),
> Google limits consent to ~25 scopes. The `recommended` scope preset includes 85+
> scopes and **will fail** for unverified apps (especially for `@gmail.com` accounts).
> Choose individual services instead to filter the scope picker:
> ```bash
> gws auth login -s drive,gmail,sheets
> ```
### Manual OAuth setup (Google Cloud Console)
Use this when `gws auth setup` cannot automate project/client creation, or when you want explicit control.
1. Open Google Cloud Console in the target project:
- OAuth consent screen: `https://console.cloud.google.com/apis/credentials/consent?project=<PROJECT_ID>`
- Credentials: `https://console.cloud.google.com/apis/credentials?project=<PROJECT_ID>`
2. Configure OAuth branding/audience if prompted:
- App type: **External** (testing mode is fine)
3. Add your account under **Test users**
4. Create an OAuth client:
- Type: **Desktop app**
5. Download the client JSON and save it to:
- `~/.config/gws/client_secret.json`
> [!IMPORTANT]
> **You must add yourself as a test user.** In the OAuth consent screen, click
> **Test users → Add users** and enter your Google account email. Without this,
> login will fail with a generic "Access blocked" error.
Then run:
```bash
gws auth login
```
### Browser-assisted auth (human or agent)
You can complete OAuth either manually or with browser automation.
- **Human flow**: run `gws auth login`, open the printed URL, approve scopes.
- **Agent-assisted flow**: the agent opens the URL, selects account, handles consent prompts, and returns control once the localhost callback succeeds.
If consent shows **"Google hasn't verified this app"** (testing mode), click **Continue**.
If scope checkboxes appear, select required scopes (or **Select all**) before continuing.
### Headless / CI (export flow)
1. Complete interactive auth on a machine with a browser.
2. Export credentials:
```bash
gws auth export --unmasked > credentials.json
```
3. On the headless machine:
```bash
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
gws drive files list # just works
```
### Service Account (server-to-server)
Point to your key file; no login needed.
```bash
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/service-account.json
gws drive files list
```
### Pre-obtained Access Token
Useful when another tool (e.g. `gcloud`) already mints tokens for your environment.
```bash
export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)
```
### Precedence
| Priority | Source | Set via |
| -------- | ---------------------- | --------------------------------------- |
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
| 3 | Encrypted credentials | `gws auth login` |
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
Environment variables can also live in a `.env` file.
## AI Agent Skills
The repo ships 100+ Agent Skills (`SKILL.md` files) — one for every supported API, plus higher-level helpers for common workflows and 50 curated recipes for Gmail, Drive, Docs, Calendar, and Sheets. See the full [Skills Index](docs/skills.md) for the complete list.
```bash
# Install all skills at once
npx skills add https://github.com/googleworkspace/cli
# Or pick only what you need
npx skills add https://github.com/googleworkspace/cli/tree/main/skills/gws-drive
npx skills add https://github.com/googleworkspace/cli/tree/main/skills/gws-gmail
```
<details>
<summary>OpenClaw setup</summary>
```bash
# Symlink all skills (stays in sync with repo)
ln -s $(pwd)/skills/gws-* ~/.openclaw/skills/
# Or copy specific skills
cp -r skills/gws-drive skills/gws-gmail ~/.openclaw/skills/
```
The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the CLI via `npm` if `gws` isn't on PATH.
</details>
## Gemini CLI Extension
1. Authenticate the CLI first:
```bash
gws auth setup
```
2. Install the extension into the Gemini CLI:
```bash
gemini extensions install https://github.com/googleworkspace/cli
```
Installing this extension gives your Gemini CLI agent direct access to all `gws` commands and Google Workspace agent skills. Because `gws` handles its own authentication securely, you simply need to authenticate your terminal once prior to using the agent, and the extension will automatically inherit your credentials.
## Advanced Usage
### Multipart Uploads
```bash
gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf
```
### Pagination
| Flag | Description | Default |
| ------------------- | ---------------------------------------------- | ------- |
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
| `--page-limit <N>` | Max pages to fetch | 10 |
| `--page-delay <MS>` | Delay between pages | 100 ms |
### Google Sheets — Shell Escaping
Sheets ranges use `!` which bash interprets as history expansion. Always wrap values in **single quotes**:
```bash
# Read cells A1:C10 from "Sheet1"
gws sheets spreadsheets values get \
--params '{"spreadsheetId": "SPREADSHEET_ID", "range": "Sheet1!A1:C10"}'
# Append rows
gws sheets spreadsheets values append \
--params '{"spreadsheetId": "ID", "range": "Sheet1!A1", "valueInputOption": "USER_ENTERED"}' \
--json '{"values": [["Name", "Score"], ["Alice", 95]]}'
```
### Helper Commands
Some services ship hand-crafted helper commands alongside the auto-generated Discovery surface. Helper commands are prefixed with `+` so they are visually distinct and never collide with Discovery-generated method names.
Time-aware helpers (`+agenda`, `+standup-report`, `+weekly-digest`, `+meeting-prep`) automatically use your **Google account timezone** (fetched from Calendar Settings API and cached for 24 hours). Override with `--timezone`/`--tz` on `+agenda`, or set the `--timezone` flag for explicit control.
Run `gws <service> --help` to see both Discovery methods and helper commands together.
```bash
gws gmail --help # shows +send, +reply, +reply-all, +forward, +triage, +watch …
gws calendar --help # shows +insert, +agenda …
gws drive --help # shows +upload …
```
**Full helper reference:**
| Service | Command | Description |
|---------|---------|-------------|
| `gmail` | `+send` | Send an email |
| `gmail` | `+reply` | Reply to a message (handles threading automatically) |
| `gmail` | `+reply-all` | Reply-all to a message |
| `gmail` | `+forward` | Forward a message to new recipients |
| `gmail` | `+triage` | Show unread inbox summary (sender, subject, date) |
| `gmail` | `+watch` | Watch for new emails and stream them as NDJSON |
| `sheets` | `+append` | Append a row to a spreadsheet |
| `sheets` | `+read` | Read values from a spreadsheet |
| `docs` | `+write` | Append text to a document |
| `chat` | `+send` | Send a message to a space |
| `drive` | `+upload` | Upload a file with automatic metadata |
| `calendar` | `+insert` | Create a new event |
| `calendar` | `+agenda` | Show upcoming events (uses Google account timezone; override with `--timezone`) |
| `script` | `+push` | Replace all files in an Apps Script project with local files |
| `workflow` | `+standup-report` | Today's meetings + open tasks as a standup summary |
| `workflow` | `+meeting-prep` | Prepare for your next meeting: agenda, attendees, and linked docs |
| `workflow` | `+email-to-task` | Convert a Gmail message into a Google Tasks entry |
| `workflow` | `+weekly-digest` | Weekly summary: this week's meetings + unread email count |
| `workflow` | `+file-announce` | Announce a Drive file in a Chat space |
| `events` | `+subscribe` | Subscribe to Workspace events and stream them as NDJSON |
| `events` | `+renew` | Renew/reactivate Workspace Events subscriptions |
| `modelarmor` | `+sanitize-prompt` | Sanitize a user prompt through a Model Armor template |
| `modelarmor` | `+sanitize-response` | Sanitize a model response through a Model Armor template |
| `modelarmor` | `+create-template` | Create a new Model Armor template |
**Examples:**
```bash
# Send an email
gws gmail +send --to alice@example.com --subject "Hello" --body "Hi there"
# Reply to a message
gws gmail +reply --message-id MESSAGE_ID --body "Thanks!"
# Append a row to a spreadsheet
gws sheets +append --spreadsheet SPREADSHEET_ID --values "Alice,95"
# Show today's calendar agenda
gws calendar +agenda
# Upload a file to Drive
gws drive +upload ./report.pdf --name "Q1 Report"
# Morning standup summary
gws workflow +standup-report
# Show today's agenda in a specific timezone
gws calendar +agenda --today --timezone America/New_York
```
### Model Armor (Response Sanitization)
Integrate [Google Cloud Model Armor](https://cloud.google.com/security/products/model-armor) to scan API responses for prompt injection before they reach your agent.
```bash
gws gmail users messages get --params '...' \
--sanitize "projects/P/locations/L/templates/T"
```
| Variable | Description |
| ---------------------------------------- | ---------------------------- |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
## Environment Variables
All variables are optional. See [`.env.example`](.env.example) for a copy-paste template.
| Variable | Description |
|---|---|
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) |
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) |
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
| `GOOGLE_WORKSPACE_CLI_LOG` | Log level for stderr (e.g., `gws=debug`). Off by default. |
| `GOOGLE_WORKSPACE_CLI_LOG_FILE` | Directory for JSON log files with daily rotation. Off by default. |
| `GOOGLE_WORKSPACE_PROJECT_ID` | GCP project ID override for quota/billing and fallback for helper commands |
Environment variables can also be set in a `.env` file (loaded via [dotenvy](https://crates.io/crates/dotenvy)).
## Exit Codes
`gws` uses structured exit codes so scripts can branch on the failure type without parsing error output.
| Code | Meaning | Example cause |
|------|---------|---------------|
| `0` | Success | Command completed normally |
| `1` | API error | Google returned a 4xx/5xx response |
| `2` | Auth error | Credentials missing, expired, or invalid |
| `3` | Validation error | Bad arguments, unknown service, invalid flag |
| `4` | Discovery error | Could not fetch the API schema document |
| `5` | Internal error | Unexpected failure |
```bash
gws drive files list --params '{"fileId": "bad"}'
echo $? # 1 — API error
gws unknown-service files list
echo $? # 3 — validation error (unknown service)
```
## Architecture
`gws` uses a **two-phase parsing** strategy:
1. Read `argv[1]` to identify the service (e.g. `drive`)
2. Fetch the service's Discovery Document (cached 24 h)
3. Build a `clap::Command` tree from the document's resources and methods
4. Re-parse the remaining arguments
5. Authenticate, build the HTTP request, execute
All output — success, errors, download metadata — is structured JSON.
## Troubleshooting
### "Access blocked" or 403 during login
Your OAuth app is in **testing mode** and your account is not listed as a test user.
**Fix:** Open the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in your GCP project → **Test users** → **Add users** → enter your Google account email. Then retry `gws auth login`.
### "Google hasn't verified this app"
Expected when your app is in testing mode. Click **Advanced** → **Go to \<app name\> (unsafe)** to proceed. This is safe for personal use; verification is only required to publish the app to other users.
### Too many scopes / consent screen error
Unverified (testing mode) apps are limited to ~25 OAuth scopes. The `recommended` scope preset includes many scopes and will exceed this limit.
**Fix:** Select only the scopes you need:
```bash
gws auth login --scopes drive,gmail,calendar
```
### `gcloud` CLI not found
`gws auth setup` requires the `gcloud` CLI to automate project creation. You have three options:
1. [Install gcloud](https://cloud.google.com/sdk/docs/install) and use `gcloud` directly.
2. Re-run `gws auth setup` which wraps `gcloud` calls.
3. Skip `gcloud` entirely — set up OAuth credentials manually in the [Cloud Console](#manual-oauth-setup-google-cloud-console)
### `redirect_uri_mismatch`
The OAuth client was not created as a **Desktop app** type. In the [Credentials](https://console.cloud.google.com/apis/credentials) page, delete the existing client, create a new one with type **Desktop app**, and download the new JSON.
### API not enabled — `accessNotConfigured`
If a required Google API is not enabled for your GCP project, you will see a
403 error with reason `accessNotConfigured`:
```json
{
"error": {
"code": 403,
"message": "Gmail API has not been used in project 549352339482 ...",
"reason": "accessNotConfigured",
"enable_url": "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482"
}
}
```
`gws` also prints an actionable hint to **stderr**:
```
💡 API not enabled for your GCP project.
Enable it at: https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482
After enabling, wait a few seconds and retry your command.
```
**Steps to fix:**
1. Click the `enable_url` link (or copy it from the `enable_url` JSON field).
2. In the GCP Console, click **Enable**.
3. Wait ~10 seconds, then retry your `gws` command.
> [!TIP]
> You can also run `gws auth setup` which walks you through enabling all required
> APIs for your project automatically.
## Development
```bash
cargo build # dev build
cargo clippy -- -D warnings # lint
cargo test # unit tests
./scripts/coverage.sh # HTML coverage report → target/llvm-cov/html/
```
## License
Apache-2.0
## Disclaimer
> [!CAUTION]
> This is **not** an officially supported Google product.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`googleworkspace/cli`
- 原始仓库:https://github.com/googleworkspace/cli
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+6
View File
@@ -0,0 +1,6 @@
# Report a security issue
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz). We use
[https://g.co/vulnz](https://g.co/vulnz) for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on [https://g.co/vulnz](https://g.co/vulnz).
+12
View File
@@ -0,0 +1,12 @@
╔════════════════════════════════════════════════╗
║ ║
║ ✨ Feature Roll ✨ ║
║ ║
║ ✅ Compatible with Headless Envs ║
║ 🔒 Can be Scoped to Read-Only ║
║ 🦀 Zero Runtime (Static Binary) ║
║ 📄 Auto-Pagination (NDJSON) ║
║ 🧠 Type-Safe Discovery Schemas ║
║ ║
╚════════════════════════════════════════════════╝
+29
View File
@@ -0,0 +1,29 @@
╔════════════════════════════════════════════════╗
║ ║
║ ██████╗ ██╗ ██╗███████╗ ║
║ ██╔════╝ ██║ ██║██╔════╝ ║
║ ██║ ███╗██║█╗ ██║███████╗ ║
║ ██║ ██║██║███╗ ██║╚════██║ ║
║ ╚██████╔╝╚███╔ ███╔╝███████║ ║
║ ╚═════╝ ╚══╝ ╚══╝ ╚══════╝ ║
║ ║
║ Google Workspace CLI ║
║ ───────────────────── ║
║ One tool. Every API. ║
║ ║
╚════════════════════════════════════════════════╝
⭐ github.com/googleworkspace/cli
npm i -g @googleworkspace/cli
────────────────────────────────
Built with Rust. 🦀
────────────────────────────────
🚀 Drive 📧 Gmail
📅 Calendar 📊 Sheets
📝 Docs 🎨 Slides
💬 Chat 👥 Admin
+29
View File
@@ -0,0 +1,29 @@
╔════════════════════════════════════════════════╗
║ ║
║ ██████╗ ██╗ ██╗███████╗ ║
║ ██╔════╝ ██║ ██║██╔════╝ ║
║ ██║ ███╗██║█╗ ██║███████╗ ║
║ ██║ ██║██║███╗ ██║╚════██║ ║
║ ╚██████╔╝╚███╔ ███╔╝███████║ ║
║ ╚═════╝ ╚══╝ ╚══╝ ╚══════╝ ║
║ ║
║ Google Workspace CLI ║
║ ───────────────────── ║
║ One tool. Every API. ║
║ ║
╚════════════════════════════════════════════════╝
⭐ github.com/googleworkspace/cli
npm i -g @googleworkspace/cli
────────────────────────────────
Built with Rust. 🦀
────────────────────────────────
🚀 Drive 📧 Gmail
📅 Calendar 📊 Sheets
📝 Docs 🎨 Slides
💬 Chat 👥 Admin
+32
View File
@@ -0,0 +1,32 @@
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
                               
+12
View File
@@ -0,0 +1,12 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 WHAT IS GWS?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
A single CLI for ALL Google Workspace APIs.
Perfect for:
🤖 AI agents
📜 Shell scripts
⚡ Power users
📊 Automation
+3
View File
@@ -0,0 +1,3 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📂 EXPLORE SERVICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+3
View File
@@ -0,0 +1,3 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 INSPECT DRIVE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+7
View File
@@ -0,0 +1,7 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 INTROSPECT APIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
What params does an API
method accept? Just ask.
+3
View File
@@ -0,0 +1,3 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 JSON SCHEMAS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+4
View File
@@ -0,0 +1,4 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🗂️ List files in a folder
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+4
View File
@@ -0,0 +1,4 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📤 Upload to Drive
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+4
View File
@@ -0,0 +1,4 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📧 Send an email
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+4
View File
@@ -0,0 +1,4 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📅 Schedule a meeting
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+4
View File
@@ -0,0 +1,4 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Log data to Sheets
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+8
View File
@@ -0,0 +1,8 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
♾️ Paginate all pages
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--page-all streams NDJSON
from every page.
Pipe to jq for processing.
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "google-workspace-cli"
version = "0.22.5"
edition = "2021"
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "gws"
path = "src/main.rs"
[dependencies]
google-workspace = { version = "0.22.5", path = "../google-workspace" }
tempfile = "3"
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
yup-oauth2 = "12"
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
base64 = "0.22.1"
derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
mail-builder = "0.4"
async-trait = "0.1.89"
toml = "0.8"
percent-encoding = "2.3.2"
zeroize = { version = "1.8.2", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }
mime_guess2 = "2.3.1"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
keyring = "3.6.3"
[dev-dependencies]
serial_test = "3.4.0"
+33
View File
@@ -0,0 +1,33 @@
# google-workspace-cli
**One CLI for all of Google Workspace — built for humans and AI agents.**
`gws` dynamically generates its command surface at runtime by reading Google's [Discovery Service](https://developers.google.com/discovery). Drive, Gmail, Calendar, and every Workspace API — zero boilerplate, structured JSON output, 40+ agent skills included.
## Install
Download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page.
Alternatively, you can use package managers as a convenience layer:
```bash
npm install -g @googleworkspace/cli # npm (downloads GitHub release binary)
cargo install google-workspace-cli # crates.io
nix run github:googleworkspace/cli # nix
```
## Quick Start
```bash
gws auth login
gws drive files list --params '{"pageSize": 5}'
gws gmail users.messages list --params '{"maxResults": 3}'
```
## Documentation
See the [full README](https://github.com/googleworkspace/cli#readme) for authentication setup, helper commands, agent skills, and more.
## License
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
@@ -0,0 +1,190 @@
[[personas]]
name = "exec-assistant"
title = "Executive Assistant"
description = "Manage an executive's schedule, inbox, and communications."
services = [ "gmail", "calendar", "drive", "chat" ]
workflows = [ "+standup-report", "+meeting-prep", "+weekly-digest" ]
instructions = [
"Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks.",
"Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs.",
"Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership.",
"Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`.",
"Draft replies with `gws gmail +send` — keep tone professional and concise."
]
tips = [
"Always confirm calendar changes with the executive before committing.",
"Use `--format table` for quick visual scans of agenda and triage output.",
"Check `gws calendar +agenda --week` on Monday mornings for weekly planning."
]
[[personas]]
name = "project-manager"
title = "Project Manager"
description = "Coordinate projects — track tasks, schedule meetings, and share docs."
services = [ "drive", "sheets", "calendar", "gmail", "chat" ]
workflows = [ "+standup-report", "+weekly-digest", "+file-announce" ]
instructions = [
"Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items.",
"Track project status in Sheets using `gws sheets +append` to log updates.",
"Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`.",
"Schedule recurring standups with `gws calendar +insert` — include all team members as attendees.",
"Send status update emails to stakeholders with `gws gmail +send`."
]
tips = [
"Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders.",
"Pipe triage output through `jq` for filtering by sender or subject.",
"Use `--dry-run` before any write operations to preview what will happen."
]
[[personas]]
name = "hr-coordinator"
title = "HR Coordinator"
description = "Handle HR workflows — onboarding, announcements, and employee comms."
services = [ "gmail", "calendar", "drive", "chat" ]
workflows = [ "+email-to-task", "+file-announce" ]
instructions = [
"For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`.",
"Upload onboarding docs to a shared Drive folder with `gws drive +upload`.",
"Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc.",
"Convert email requests into tracked tasks with `gws workflow +email-to-task`.",
"Send bulk announcements with `gws gmail +send` — use clear subject lines."
]
tips = [
"Always use `--sanitize` for PII-sensitive operations.",
"Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules."
]
[[personas]]
name = "sales-ops"
title = "Sales Operations"
description = "Manage sales workflows — track deals, schedule calls, client comms."
services = [ "gmail", "calendar", "sheets", "drive" ]
workflows = [ "+meeting-prep", "+email-to-task", "+weekly-digest" ]
instructions = [
"Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda.",
"Log deal updates in a tracking spreadsheet with `gws sheets +append`.",
"Convert follow-up emails into tasks with `gws workflow +email-to-task`.",
"Share proposals by uploading to Drive with `gws drive +upload`.",
"Get a weekly sales pipeline summary with `gws workflow +weekly-digest`."
]
tips = [
"Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails.",
"Schedule follow-up calls immediately after meetings to maintain momentum.",
"Keep all client-facing documents in a dedicated shared Drive folder."
]
[[personas]]
name = "it-admin"
title = "IT Administrator"
description = "Administer IT — monitor security and configure Workspace."
services = [ "gmail", "drive", "calendar" ]
workflows = [ "+standup-report" ]
instructions = [
"Start the day with `gws workflow +standup-report` to review any pending IT requests.",
"Monitor suspicious login activity and review audit logs.",
"Configure Drive sharing policies to enforce organizational security."
]
tips = [
"Always use `--dry-run` before bulk operations.",
"Review `gws auth status` regularly to verify service account permissions."
]
[[personas]]
name = "content-creator"
title = "Content Creator"
description = "Create, organize, and distribute content across Workspace."
services = [ "docs", "drive", "gmail", "chat", "slides" ]
workflows = [ "+file-announce" ]
instructions = [
"Draft content in Google Docs with `gws docs +write`.",
"Organize content assets in Drive folders — use `gws drive files list` to browse.",
"Share finished content by announcing in Chat with `gws workflow +file-announce`.",
"Send content review requests via email with `gws gmail +send`.",
"Upload media assets to Drive with `gws drive +upload`."
]
tips = [
"Use `gws docs +write` for quick content updates — it handles the Docs API formatting.",
"Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules.",
"Use `--format yaml` for human-readable output when debugging API responses."
]
[[personas]]
name = "customer-support"
title = "Customer Support Agent"
description = "Manage customer support — track tickets, respond, escalate issues."
services = [ "gmail", "sheets", "chat", "calendar" ]
workflows = [ "+email-to-task", "+standup-report" ]
instructions = [
"Triage the support inbox with `gws gmail +triage --query 'label:support'`.",
"Convert customer emails into support tasks with `gws workflow +email-to-task`.",
"Log ticket status updates in a tracking sheet with `gws sheets +append`.",
"Escalate urgent issues to the team Chat space.",
"Schedule follow-up calls with customers using `gws calendar +insert`."
]
tips = [
"Use `gws gmail +triage --labels` to see email categories at a glance.",
"Set up Gmail filters for auto-labeling support requests.",
"Use `--format table` for quick status dashboard views."
]
[[personas]]
name = "event-coordinator"
title = "Event Coordinator"
description = "Plan and manage events — scheduling, invitations, and logistics."
services = [ "calendar", "gmail", "drive", "chat", "sheets" ]
workflows = [ "+meeting-prep", "+file-announce", "+weekly-digest" ]
instructions = [
"Create event calendar entries with `gws calendar +insert` — include location and attendee lists.",
"Prepare event materials and upload to Drive with `gws drive +upload`.",
"Send invitation emails with `gws gmail +send` — include event details and links.",
"Announce updates in Chat spaces with `gws workflow +file-announce`.",
"Track RSVPs and logistics in Sheets with `gws sheets +append`."
]
tips = [
"Use `gws calendar +agenda --days 30` for long-range event planning.",
"Create a dedicated calendar for each major event series.",
"Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites."
]
[[personas]]
name = "team-lead"
title = "Team Lead"
description = "Lead a team — run standups, coordinate tasks, and communicate."
services = [ "calendar", "gmail", "chat", "drive", "sheets" ]
workflows = [
"+standup-report",
"+meeting-prep",
"+weekly-digest",
"+email-to-task"
]
instructions = [
"Run daily standups with `gws workflow +standup-report` — share output in team Chat.",
"Prepare for 1:1s with `gws workflow +meeting-prep`.",
"Get weekly snapshots with `gws workflow +weekly-digest`.",
"Delegate email action items with `gws workflow +email-to-task`.",
"Track team OKRs in a shared Sheet with `gws sheets +append`."
]
tips = [
"Use `gws calendar +agenda --week --format table` for weekly team calendar views.",
"Pipe standup reports to Chat with `gws chat spaces messages create`.",
"Use `--sanitize` for any operations involving sensitive team data."
]
[[personas]]
name = "researcher"
title = "Researcher"
description = "Organize research — manage references, notes, and collaboration."
services = [ "drive", "docs", "sheets", "gmail" ]
workflows = [ "+file-announce" ]
instructions = [
"Organize research papers and notes in Drive folders.",
"Write research notes and summaries with `gws docs +write`.",
"Track research data in Sheets — use `gws sheets +append` for data logging.",
"Share findings with collaborators via `gws workflow +file-announce`.",
"Request peer reviews via `gws gmail +send`."
]
tips = [
"Use `gws drive files list` with search queries to find specific documents.",
"Keep a running log of experiments and findings in a shared Sheet.",
"Use `--format csv` when exporting data for analysis tools."
]
@@ -0,0 +1,496 @@
[[recipes]]
name = "label-and-archive-emails"
title = "Label and Archive Gmail Threads"
description = "Apply Gmail labels to matching messages and archive them to keep your inbox clean."
category = "productivity"
services = [ "gmail" ]
steps = [
"Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`",
"Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`",
"Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`"
]
[[recipes]]
name = "draft-email-from-doc"
title = "Draft a Gmail Message from a Google Doc"
description = "Read content from a Google Doc and use it as the body of a Gmail message."
category = "productivity"
services = [ "docs", "gmail" ]
steps = [
"Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`",
"Copy the text from the body content",
"Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`"
]
[[recipes]]
name = "organize-drive-folder"
title = "Organize Files into Google Drive Folders"
description = "Create a Google Drive folder structure and move files into the right locations."
category = "productivity"
services = [ "drive" ]
steps = [
"Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`",
"Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`",
"Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`",
"Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`"
]
[[recipes]]
name = "share-folder-with-team"
title = "Share a Google Drive Folder with a Team"
description = "Share a Google Drive folder and all its contents with a list of collaborators."
category = "productivity"
services = [ "drive" ]
steps = [
"Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`",
"Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`",
"Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`",
"Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`"
]
[[recipes]]
name = "email-drive-link"
title = "Email a Google Drive File Link"
description = "Share a Google Drive file and email the link with a message to recipients."
category = "productivity"
services = [ "drive", "gmail" ]
steps = [
"Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`",
"Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`",
"Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`"
]
[[recipes]]
name = "create-doc-from-template"
title = "Create a Google Doc from a Template"
description = "Copy a Google Docs template, fill in content, and share with collaborators."
category = "productivity"
services = [ "drive", "docs" ]
steps = [
"Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`",
"Get the new doc ID from the response",
"Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`",
"Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
]
[[recipes]]
name = "create-expense-tracker"
title = "Create a Google Sheets Expense Tracker"
description = "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries."
category = "productivity"
services = [ "sheets", "drive" ]
steps = [
"Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`",
"Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`",
"Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`",
"Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`"
]
[[recipes]]
name = "copy-sheet-for-new-month"
title = "Copy a Google Sheet for a New Month"
description = "Duplicate a Google Sheets template tab for a new month of tracking."
category = "productivity"
services = [ "sheets" ]
steps = [
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
"Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`",
"Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`"
]
[[recipes]]
name = "block-focus-time"
title = "Block Focus Time on Google Calendar"
description = "Create recurring focus time blocks on Google Calendar to protect deep work hours."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`",
"Verify it shows as busy: `gws calendar +agenda`"
]
[[recipes]]
name = "reschedule-meeting"
title = "Reschedule a Google Calendar Meeting"
description = "Move a Google Calendar event to a new time and automatically notify all attendees."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Find the event: `gws calendar +agenda`",
"Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`"
]
[[recipes]]
name = "create-gmail-filter"
title = "Create a Gmail Filter"
description = "Create a Gmail filter to automatically label, star, or categorize incoming messages."
category = "productivity"
services = [ "gmail" ]
steps = [
"List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`",
"Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`",
"Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`",
"Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`"
]
[[recipes]]
name = "schedule-recurring-event"
title = "Schedule a Recurring Meeting"
description = "Create a recurring Google Calendar event with attendees."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`",
"Verify it was created: `gws calendar +agenda --days 14 --format table`"
]
[[recipes]]
name = "find-free-time"
title = "Find Free Time Across Calendars"
description = "Query Google Calendar free/busy status for multiple users to find a meeting slot."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`",
"Review the output to find overlapping free slots",
"Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`"
]
[[recipes]]
name = "bulk-download-folder"
title = "Bulk Download Drive Folder"
description = "List and download all files from a Google Drive folder."
category = "productivity"
services = [ "drive" ]
steps = [
"List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`",
"Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`",
"Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`"
]
[[recipes]]
name = "find-large-files"
title = "Find Largest Files in Drive"
description = "Identify large Google Drive files consuming storage quota."
category = "productivity"
services = [ "drive" ]
steps = [
"List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`",
"Review the output and identify files to archive or move"
]
[[recipes]]
name = "create-shared-drive"
title = "Create and Configure a Shared Drive"
description = "Create a Google Shared Drive and add members with appropriate roles."
category = "productivity"
services = [ "drive" ]
steps = [
"Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`",
"Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`",
"List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`"
]
[[recipes]]
name = "log-deal-update"
title = "Log Deal Update to Sheet"
description = "Append a deal status update to a Google Sheets sales tracking spreadsheet."
category = "sales"
services = [ "sheets", "drive" ]
steps = [
"Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`",
"Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`",
"Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"$50,000\", \"Q2\", \"jdoe\"]'`"
]
[[recipes]]
name = "collect-form-responses"
title = "Check Form Responses"
description = "Retrieve and review responses from a Google Form."
category = "productivity"
services = [ "forms" ]
steps = [
"List forms: `gws forms forms list` (if you don't have the form ID)",
"Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`",
"Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`"
]
[[recipes]]
name = "post-mortem-setup"
title = "Set Up Post-Mortem"
description = "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat."
category = "engineering"
services = [ "docs", "calendar", "chat" ]
steps = [
"Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`",
"Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`",
"Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`"
]
[[recipes]]
name = "create-task-list"
title = "Create a Task List and Add Tasks"
description = "Set up a new Google Tasks list with initial tasks."
category = "productivity"
services = [ "tasks" ]
steps = [
"Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`",
"Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`",
"Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`",
"List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`"
]
[[recipes]]
name = "review-overdue-tasks"
title = "Review Overdue Tasks"
description = "Find Google Tasks that are past due and need attention."
category = "productivity"
services = [ "tasks" ]
steps = [
"List task lists: `gws tasks tasklists list --format table`",
"List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`",
"Review due dates and prioritize overdue items"
]
[[recipes]]
name = "watch-drive-changes"
title = "Watch for Drive Changes"
description = "Subscribe to change notifications on a Google Drive file or folder."
category = "engineering"
services = [ "events" ]
steps = [
"Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`",
"List active subscriptions: `gws events subscriptions list`",
"Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`"
]
[[recipes]]
name = "create-classroom-course"
title = "Create a Google Classroom Course"
description = "Create a Google Classroom course and invite students."
category = "education"
services = [ "classroom" ]
steps = [
"Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`",
"Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`",
"List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`"
]
[[recipes]]
name = "create-meet-space"
title = "Create a Google Meet Conference"
description = "Create a Google Meet meeting space and share the join link."
category = "scheduling"
services = [ "meet", "gmail" ]
steps = [
"Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`",
"Copy the meeting URI from the response",
"Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`"
]
[[recipes]]
name = "review-meet-participants"
title = "Review Google Meet Attendance"
description = "Review who attended a Google Meet conference and for how long."
category = "productivity"
services = [ "meet" ]
steps = [
"List recent conferences: `gws meet conferenceRecords list --format table`",
"List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`",
"Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`"
]
[[recipes]]
name = "create-presentation"
title = "Create a Google Slides Presentation"
description = "Create a new Google Slides presentation and add initial slides."
category = "productivity"
services = [ "slides" ]
steps = [
"Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`",
"Get the presentation ID from the response",
"Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
]
[[recipes]]
name = "save-email-attachments"
title = "Save Gmail Attachments to Google Drive"
description = "Find Gmail messages with attachments and save them to a Google Drive folder."
category = "productivity"
services = [ "gmail", "drive" ]
steps = [
"Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`",
"Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`",
"Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`",
"Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`"
]
[[recipes]]
name = "send-team-announcement"
title = "Announce via Gmail and Google Chat"
description = "Send a team announcement via both Gmail and a Google Chat space."
category = "communication"
services = [ "gmail", "chat" ]
steps = [
"Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`",
"Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`"
]
[[recipes]]
name = "create-feedback-form"
title = "Create and Share a Google Form"
description = "Create a Google Form for feedback and share it via Gmail."
category = "productivity"
services = [ "forms", "gmail" ]
steps = [
"Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`",
"Get the form URL from the response (responderUri field)",
"Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`"
]
[[recipes]]
name = "sync-contacts-to-sheet"
title = "Export Google Contacts to Sheets"
description = "Export Google Contacts directory to a Google Sheets spreadsheet."
category = "productivity"
services = [ "people", "sheets" ]
steps = [
"List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`",
"Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`",
"Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`"
]
[[recipes]]
name = "share-event-materials"
title = "Share Files with Meeting Attendees"
description = "Share Google Drive files with all attendees of a Google Calendar event."
category = "productivity"
services = [ "calendar", "drive" ]
steps = [
"Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`",
"Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`"
]
[[recipes]]
name = "create-vacation-responder"
title = "Set Up a Gmail Vacation Responder"
description = "Enable a Gmail out-of-office auto-reply with a custom message and date range."
category = "productivity"
services = [ "gmail" ]
steps = [
"Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`",
"Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`",
"Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`"
]
[[recipes]]
name = "create-events-from-sheet"
title = "Create Google Calendar Events from a Sheet"
description = "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row."
category = "productivity"
services = [ "sheets", "calendar" ]
steps = [
"Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`",
"For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`"
]
[[recipes]]
name = "plan-weekly-schedule"
title = "Plan Your Weekly Google Calendar Schedule"
description = "Review your Google Calendar week, identify gaps, and add events to fill them."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Check this week's agenda: `gws calendar +agenda`",
"Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`",
"Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`",
"Review updated schedule: `gws calendar +agenda`"
]
[[recipes]]
name = "share-doc-and-notify"
title = "Share a Google Doc and Notify Collaborators"
description = "Share a Google Docs document with edit access and email collaborators the link."
category = "productivity"
services = [ "drive", "docs", "gmail" ]
steps = [
"Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`",
"Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`",
"Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`"
]
[[recipes]]
name = "backup-sheet-as-csv"
title = "Export a Google Sheet as CSV"
description = "Export a Google Sheets spreadsheet as a CSV file for local backup or processing."
category = "productivity"
services = [ "sheets", "drive" ]
steps = [
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
"Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`",
"Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`"
]
[[recipes]]
name = "save-email-to-doc"
title = "Save a Gmail Message to Google Docs"
description = "Save a Gmail message body into a Google Doc for archival or reference."
category = "productivity"
services = [ "gmail", "docs" ]
steps = [
"Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`",
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
"Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`",
"Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`"
]
[[recipes]]
name = "compare-sheet-tabs"
title = "Compare Two Google Sheets Tabs"
description = "Read data from two tabs in a Google Sheet to compare and identify differences."
category = "productivity"
services = [ "sheets" ]
steps = [
"Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`",
"Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`",
"Compare the data and identify changes"
]
[[recipes]]
name = "batch-invite-to-event"
title = "Add Multiple Attendees to a Calendar Event"
description = "Add a list of attendees to an existing Google Calendar event and send notifications."
category = "scheduling"
services = [ "calendar" ]
steps = [
"Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
"Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`",
"Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`"
]
[[recipes]]
name = "forward-labeled-emails"
title = "Forward Labeled Gmail Messages"
description = "Find Gmail messages with a specific label and forward them to another address."
category = "productivity"
services = [ "gmail" ]
steps = [
"Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`",
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
"Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`"
]
[[recipes]]
name = "generate-report-from-sheet"
title = "Generate a Google Docs Report from Sheet Data"
description = "Read data from a Google Sheet and create a formatted Google Docs report."
category = "productivity"
services = [ "sheets", "docs", "drive" ]
steps = [
"Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`",
"Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`",
"Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: $125,000\n\n### Top Deals\n1. Acme Corp - $25,000\n2. Widget Inc - $18,000'`",
"Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`"
]
+995
View File
@@ -0,0 +1,995 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Authentication and Credential Management
//!
//! Handles obtaining OAuth 2.0 access tokens and Service Account tokens.
//! Supports local user flow (via a loopback server) and Application Default Credentials,
//! with token caching to minimize repeated authentication overhead.
use std::path::PathBuf;
use anyhow::Context;
use serde::Deserialize;
use crate::credential_store;
const PROXY_ENV_VARS: &[&str] = &[
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
];
/// Response from Google's token endpoint
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[allow(dead_code)]
expires_in: u64,
#[allow(dead_code)]
token_type: String,
}
/// Refresh an access token using reqwest (supports HTTP proxy via environment variables).
/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues.
async fn refresh_token_with_reqwest(
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> anyhow::Result<String> {
let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
let params = [
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
let response = client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.context("Failed to send token refresh request")?;
if !response.status().is_success() {
let status = response.status();
let body = response_text_or_placeholder(response.text().await);
anyhow::bail!("Token refresh failed with status {}: {}", status, body);
}
let token_response: TokenResponse = response
.json()
.await
.context("Failed to parse token response")?;
Ok(token_response.access_token)
}
/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).
///
/// Priority:
/// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable.
/// 2. `project_id` from the OAuth client configuration (`client_secret.json`).
/// 3. `quota_project_id` from Application Default Credentials (ADC).
pub fn get_quota_project() -> Option<String> {
// 1. Explicit environment variable (highest priority)
if let Ok(project_id) = std::env::var("GOOGLE_WORKSPACE_PROJECT_ID") {
if !project_id.is_empty() {
return Some(project_id);
}
}
// 2. Project ID from the OAuth client configuration (set via `gws auth setup`)
if let Ok(config) = crate::oauth_config::load_client_config() {
if !config.project_id.is_empty() {
return Some(config.project_id);
}
}
// 3. Fallback to Application Default Credentials (ADC)
let path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS")
.ok()
.map(PathBuf::from)
.or_else(adc_well_known_path)?;
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
json.get("quota_project_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Returns the well-known Application Default Credentials path:
/// `~/.config/gcloud/application_default_credentials.json`.
///
/// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is
/// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS.
fn adc_well_known_path() -> Option<PathBuf> {
dirs::home_dir().map(|d| {
d.join(".config")
.join("gcloud")
.join("application_default_credentials.json")
})
}
/// Types of credentials we support
#[derive(Debug)]
enum Credential {
AuthorizedUser(yup_oauth2::authorized_user::AuthorizedUserSecret),
ServiceAccount(yup_oauth2::ServiceAccountKey),
}
/// Fetches access tokens for a fixed set of scopes.
///
/// Long-running helpers use this trait so they can request a fresh token before
/// each API call instead of holding a single token string until it expires.
#[async_trait::async_trait]
pub trait AccessTokenProvider: Send + Sync {
async fn access_token(&self) -> anyhow::Result<String>;
}
/// A token provider backed by [`get_token`].
///
/// This keeps the scope list in one place so call sites can ask for a fresh
/// token whenever they need to make another request.
#[derive(Debug, Clone)]
pub struct ScopedTokenProvider {
scopes: Vec<String>,
}
impl ScopedTokenProvider {
pub fn new(scopes: &[&str]) -> Self {
Self {
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
}
#[async_trait::async_trait]
impl AccessTokenProvider for ScopedTokenProvider {
async fn access_token(&self) -> anyhow::Result<String> {
let scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect();
get_token(&scopes).await
}
}
pub fn token_provider(scopes: &[&str]) -> ScopedTokenProvider {
ScopedTokenProvider::new(scopes)
}
/// A fake [`AccessTokenProvider`] for tests that returns tokens from a queue.
#[cfg(test)]
pub struct FakeTokenProvider {
tokens: std::sync::Arc<tokio::sync::Mutex<std::collections::VecDeque<String>>>,
}
#[cfg(test)]
impl FakeTokenProvider {
pub fn new(tokens: impl IntoIterator<Item = &'static str>) -> Self {
Self {
tokens: std::sync::Arc::new(tokio::sync::Mutex::new(
tokens.into_iter().map(|t| t.to_string()).collect(),
)),
}
}
}
#[cfg(test)]
#[async_trait::async_trait]
impl AccessTokenProvider for FakeTokenProvider {
async fn access_token(&self) -> anyhow::Result<String> {
self.tokens
.lock()
.await
.pop_front()
.ok_or_else(|| anyhow::anyhow!("no test token remaining"))
}
}
/// Builds an OAuth2 authenticator and returns an access token.
///
/// Tries credentials in order:
/// 0. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
/// 2. Encrypted credentials at `~/.config/gws/credentials.enc`
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
/// 4. Application Default Credentials (ADC):
/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
/// (populated by `gcloud auth application-default login`)
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
// 0. Direct token from env var (highest priority, bypasses all credential loading)
if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
if !token.is_empty() {
return Ok(token);
}
}
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
let config_dir = crate::auth_commands::config_dir();
let enc_path = credential_store::encrypted_credentials_path();
let default_path = config_dir.join("credentials.json");
let token_cache = config_dir.join("token_cache.json");
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
get_token_inner(scopes, creds, &token_cache).await
}
/// Check if HTTP proxy environment variables are set
pub(crate) fn has_proxy_env() -> bool {
PROXY_ENV_VARS
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
}
pub(crate) fn response_text_or_placeholder<E>(result: Result<String, E>) -> String {
result.unwrap_or_else(|_| "(could not read error response body)".to_string())
}
async fn get_token_inner(
scopes: &[&str],
creds: Credential,
token_cache_path: &std::path::Path,
) -> anyhow::Result<String> {
match creds {
Credential::AuthorizedUser(ref secret) => {
// If proxy env vars are set, use reqwest directly (it supports proxy)
// This avoids waiting for yup-oauth2's hyper client to timeout
if has_proxy_env() {
return refresh_token_with_reqwest(
&secret.client_id,
&secret.client_secret,
&secret.refresh_token,
)
.await;
}
// No proxy - use yup-oauth2 (faster, has token caching)
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone())
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
token_cache_path.to_path_buf(),
)))
.build()
.await
.context("Failed to build authorized user authenticator")?;
let token = auth.token(scopes).await.context("Failed to get token")?;
Ok(token
.token()
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
.to_string())
}
Credential::ServiceAccount(key) => {
let tc_filename = token_cache_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "token_cache.json".to_string());
let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}"));
let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(
Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)),
);
let auth = builder
.build()
.await
.context("Failed to build service account authenticator")?;
let token = auth.token(scopes).await.context("Failed to get token")?;
Ok(token
.token()
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
.to_string())
}
}
}
/// Parse a plaintext JSON credential file into a [`Credential`].
///
/// Determines the credential type from the `"type"` field:
/// - `"service_account"` → [`Credential::ServiceAccount`]
/// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`]
///
/// Uses the already-parsed `serde_json::Value` to avoid a second string parse.
async fn parse_credential_file(
path: &std::path::Path,
content: &str,
) -> anyhow::Result<Credential> {
let json: serde_json::Value = serde_json::from_str(content)
.with_context(|| format!("Failed to parse credentials JSON at {}", path.display()))?;
if json.get("type").and_then(|v| v.as_str()) == Some("service_account") {
let key = yup_oauth2::parse_service_account_key(content).with_context(|| {
format!(
"Failed to parse service account key from {}",
path.display()
)
})?;
return Ok(Credential::ServiceAccount(key));
}
// Deserialize from the Value we already have — avoids a second string parse.
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = serde_json::from_value(json)
.with_context(|| {
format!(
"Failed to parse authorized user credentials from {}",
path.display()
)
})?;
Ok(Credential::AuthorizedUser(secret))
}
async fn load_credentials_inner(
env_file: Option<&str>,
enc_path: &std::path::Path,
default_path: &std::path::Path,
) -> anyhow::Result<Credential> {
// 1. Explicit env var — plaintext file (User or Service Account)
if let Some(path) = env_file {
let p = PathBuf::from(path);
if p.exists() {
let content = tokio::fs::read_to_string(&p)
.await
.with_context(|| format!("Failed to read credentials from {path}"))?;
return parse_credential_file(&p, &content).await;
}
anyhow::bail!(
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
);
}
// 2. Encrypted credentials
if enc_path.exists() {
match credential_store::load_encrypted_from_path(enc_path) {
Ok(json_str) => {
return parse_credential_file(enc_path, &json_str).await;
}
Err(e) => {
// Decryption failed — the encryption key likely changed (e.g. after
// an upgrade that migrated keys between keyring and file storage).
// Remove the stale file so the next `gws auth login` starts fresh,
// and fall through to other credential sources (plaintext, ADC).
eprintln!(
"Warning: removing undecryptable credentials file ({}): {e:#}",
enc_path.display()
);
if let Err(err) = tokio::fs::remove_file(enc_path).await {
eprintln!(
"Warning: failed to remove stale credentials file '{}': {err}",
enc_path.display()
);
}
// Also remove stale token caches that used the old key.
for cache_file in ["token_cache.json", "sa_token_cache.json"] {
let path = enc_path.with_file_name(cache_file);
if let Err(err) = tokio::fs::remove_file(&path).await {
if err.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"Warning: failed to remove stale token cache '{}': {err}",
path.display()
);
}
}
}
// Fall through to remaining credential sources below.
}
}
}
// 3. Plaintext credentials at default path (AuthorizedUser)
if default_path.exists() {
return Ok(Credential::AuthorizedUser(
yup_oauth2::read_authorized_user_secret(default_path)
.await
.with_context(|| {
format!("Failed to read credentials from {}", default_path.display())
})?,
));
}
// 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing)
if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
let adc_path = PathBuf::from(&adc_env);
if adc_path.exists() {
let content = tokio::fs::read_to_string(&adc_path)
.await
.with_context(|| format!("Failed to read ADC from {adc_env}"))?;
return parse_credential_file(&adc_path, &content).await;
}
anyhow::bail!(
"GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist"
);
}
// 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json
// (populated by `gcloud auth application-default login`). Silent if absent.
if let Some(well_known) = adc_well_known_path() {
if well_known.exists() {
let content = tokio::fs::read_to_string(&well_known)
.await
.with_context(|| format!("Failed to read ADC from {}", well_known.display()))?;
return parse_credential_file(&well_known, &content).await;
}
}
anyhow::bail!(
"No credentials found. Run `gws auth setup` to configure, \
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n\
Tip: Application Default Credentials (ADC) are also supported — run \
`gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS."
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
/// RAII guard that saves the current value of an environment variable and
/// restores it when dropped. This ensures cleanup even if a test panics.
struct EnvVarGuard {
name: String,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
/// Save the current value of `name`, then set it to `value`.
fn set(name: &str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let original = std::env::var_os(name);
std::env::set_var(name, value);
Self {
name: name.to_string(),
original,
}
}
/// Save the current value of `name`, then remove it.
fn remove(name: &str) -> Self {
let original = std::env::var_os(name);
std::env::remove_var(name);
Self {
name: name.to_string(),
original,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(&self.name, v),
None => std::env::remove_var(&self.name),
}
}
}
fn clear_proxy_env() -> Vec<EnvVarGuard> {
PROXY_ENV_VARS
.iter()
.map(|key| EnvVarGuard::remove(key))
.collect()
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_false_when_unset() {
let _guards = clear_proxy_env();
assert!(!has_proxy_env());
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_true_when_set() {
let mut guards = clear_proxy_env();
guards.push(EnvVarGuard::set(
"HTTPS_PROXY",
"http://proxy.internal:8080",
));
assert!(has_proxy_env());
}
#[test]
fn response_text_or_placeholder_returns_body() {
let body = response_text_or_placeholder(Result::<String, ()>::Ok("error body".to_string()));
assert_eq!(body, "error body");
}
#[test]
fn response_text_or_placeholder_returns_placeholder_on_error() {
let body = response_text_or_placeholder(Result::<String, ()>::Err(()));
assert_eq!(body, "(could not read error response body)");
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_no_options() {
// Isolate from host ADC: override HOME so adc_well_known_path()
// resolves to a non-existent directory, and clear the env var.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let err = load_credentials_inner(
None,
&PathBuf::from("/does/not/exist1"),
&PathBuf::from("/does/not/exist2"),
)
.await;
assert!(err.is_err());
assert!(err
.unwrap_err()
.to_string()
.contains("No credentials found"));
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "adc_id",
"client_secret": "adc_secret",
"refresh_token": "adc_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let _adc_guard = EnvVarGuard::set(
"GOOGLE_APPLICATION_CREDENTIALS",
file.path().to_str().unwrap(),
);
let res = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
match res.unwrap() {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "adc_id");
assert_eq!(secret.refresh_token, "adc_refresh");
}
_ => panic!("Expected AuthorizedUser from ADC"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_service_account() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"type": "service_account",
"project_id": "test-project",
"private_key_id": "adc-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
"client_email": "adc-sa@test-project.iam.gserviceaccount.com",
"client_id": "456",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}"#;
file.write_all(json.as_bytes()).unwrap();
let _adc_guard = EnvVarGuard::set(
"GOOGLE_APPLICATION_CREDENTIALS",
file.path().to_str().unwrap(),
);
let res = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
match res.unwrap() {
Credential::ServiceAccount(key) => {
assert_eq!(
key.client_email,
"adc-sa@test-project.iam.gserviceaccount.com"
);
}
_ => panic!("Expected ServiceAccount from ADC"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_adc_env_var_missing_file() {
let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json");
// When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately
// rather than falling through — the user explicitly asked for this file.
let err = load_credentials_inner(
None,
&PathBuf::from("/missing/enc"),
&PathBuf::from("/missing/plain"),
)
.await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("does not exist"),
"Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}"
);
}
#[tokio::test]
async fn test_load_credentials_env_file_missing() {
let err = load_credentials_inner(
Some("/does/not/exist"),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await;
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("does not exist"));
}
#[tokio::test]
async fn test_load_credentials_env_file_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "test_id",
"client_secret": "test_secret",
"refresh_token": "test_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(
Some(file.path().to_str().unwrap()),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "test_id");
assert_eq!(secret.refresh_token, "test_refresh");
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
async fn test_load_credentials_env_file_service_account() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"type": "service_account",
"project_id": "test",
"private_key_id": "test-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
"client_email": "test@test.iam.gserviceaccount.com",
"client_id": "123",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(
Some(file.path().to_str().unwrap()),
&PathBuf::from("/also/missing"),
&PathBuf::from("/still/missing"),
)
.await
.unwrap();
match res {
Credential::ServiceAccount(key) => {
assert_eq!(key.client_email, "test@test.iam.gserviceaccount.com");
}
_ => panic!("Expected ServiceAccount"),
}
}
#[tokio::test]
async fn test_load_credentials_default_path_authorized_user() {
let mut file = NamedTempFile::new().unwrap();
let json = r#"{
"client_id": "default_id",
"client_secret": "default_secret",
"refresh_token": "default_refresh",
"type": "authorized_user"
}"#;
file.write_all(json.as_bytes()).unwrap();
let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path())
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "default_id");
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_get_token_from_env_var() {
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "my-test-token");
}
#[tokio::test]
#[serial_test::serial]
async fn test_scoped_token_provider_uses_get_token() {
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "provider-token");
let provider = token_provider(&["https://www.googleapis.com/auth/drive"]);
let first = provider.access_token().await.unwrap();
let second = provider.access_token().await.unwrap();
assert_eq!(first, "provider-token");
assert_eq!(second, "provider-token");
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_encrypted_file() {
// Simulate an encrypted credentials file
let json = r#"{
"client_id": "enc_test_id",
"client_secret": "enc_test_secret",
"refresh_token": "enc_test_refresh",
"type": "authorized_user"
}"#;
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
// Isolate global config dir to prevent races with other tests
std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());
// Encrypt and write
let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap();
std::fs::write(&enc_path, &encrypted).unwrap();
let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist"))
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(secret.client_id, "enc_test_id");
assert_eq!(secret.client_secret, "enc_test_secret");
assert_eq!(secret.refresh_token, "enc_test_refresh");
}
_ => panic!("Expected AuthorizedUser from encrypted credentials"),
}
}
#[tokio::test]
async fn test_load_credentials_encrypted_takes_priority_over_default() {
// Encrypted credentials should be loaded before the default plaintext path
let enc_json = r#"{
"client_id": "encrypted_id",
"client_secret": "encrypted_secret",
"refresh_token": "encrypted_refresh",
"type": "authorized_user"
}"#;
let plain_json = r#"{
"client_id": "plaintext_id",
"client_secret": "plaintext_secret",
"refresh_token": "plaintext_refresh",
"type": "authorized_user"
}"#;
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
let plain_path = dir.path().join("credentials.json");
let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).unwrap();
std::fs::write(&enc_path, &encrypted).unwrap();
std::fs::write(&plain_path, plain_json).unwrap();
let res = load_credentials_inner(None, &enc_path, &plain_path)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(
secret.client_id, "encrypted_id",
"Encrypted credentials should take priority over plaintext"
);
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_corrupt_encrypted_file_is_removed() {
// When credentials.enc cannot be decrypted, the file should be removed
// automatically and the function should fall through to other sources.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
// Write garbage data that cannot be decrypted.
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
.await
.unwrap();
assert!(enc_path.exists());
let result =
load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await;
// Should fall through to "No credentials found" (not a decryption error).
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("No credentials found"),
"Should fall through to final error, got: {msg}"
);
assert!(
!enc_path.exists(),
"Stale credentials.enc must be removed after decryption failure"
);
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_corrupt_encrypted_falls_through_to_plaintext() {
// When credentials.enc is corrupt but a valid plaintext file exists,
// the function should fall through and use the plaintext credentials.
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");
let plain_path = dir.path().join("credentials.json");
// Write garbage encrypted data.
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
.await
.unwrap();
// Write valid plaintext credentials.
let plain_json = r#"{
"client_id": "fallback_id",
"client_secret": "fallback_secret",
"refresh_token": "fallback_refresh",
"type": "authorized_user"
}"#;
tokio::fs::write(&plain_path, plain_json).await.unwrap();
let res = load_credentials_inner(None, &enc_path, &plain_path)
.await
.unwrap();
match res {
Credential::AuthorizedUser(secret) => {
assert_eq!(
secret.client_id, "fallback_id",
"Should fall through to plaintext credentials"
);
}
_ => panic!("Expected AuthorizedUser from plaintext fallback"),
}
assert!(!enc_path.exists(), "Stale credentials.enc must be removed");
}
#[tokio::test]
#[serial_test::serial]
async fn test_get_token_env_var_empty_falls_through() {
// An empty token should not short-circuit — it should be ignored
// and fall through to normal credential loading.
// Isolate from host ADC so the well-known path doesn't match.
let tmp = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "");
let result = load_credentials_inner(
None,
&PathBuf::from("/does/not/exist1"),
&PathBuf::from("/does/not/exist2"),
)
.await;
// Should fall through to normal credential loading, which fails
// because we pointed at non-existent paths
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No credentials found"));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_env_var() {
let _env_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_PROJECT_ID", "priority-env");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
assert_eq!(get_quota_project(), Some("priority-env".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_config() {
let tmp = tempfile::tempdir().unwrap();
let _config_guard = EnvVarGuard::set(
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
tmp.path().to_str().unwrap(),
);
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
// Save a client config with a project ID
crate::oauth_config::save_client_config("id", "secret", "config-project").unwrap();
assert_eq!(get_quota_project(), Some("config-project".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_priority_adc_fallback() {
let tmp = tempfile::tempdir().unwrap();
let adc_dir = tmp.path().join(".config").join("gcloud");
std::fs::create_dir_all(&adc_dir).unwrap();
std::fs::write(
adc_dir.join("application_default_credentials.json"),
r#"{"quota_project_id": "adc-project"}"#,
)
.unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
assert_eq!(get_quota_project(), Some("adc-project".to_string()));
}
#[test]
#[serial_test::serial]
fn test_get_quota_project_reads_adc() {
let tmp = tempfile::tempdir().unwrap();
let adc_dir = tmp.path().join(".config").join("gcloud");
std::fs::create_dir_all(&adc_dir).unwrap();
std::fs::write(
adc_dir.join("application_default_credentials.json"),
r#"{"quota_project_id": "my-project-123"}"#,
)
.unwrap();
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
// Isolate from local environment
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
assert_eq!(get_quota_project(), Some("my-project-123".to_string()));
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! HTTP client — re-exports from `google_workspace` library crate.
pub use google_workspace::client::*;
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use clap::{Arg, Command};
use crate::discovery::{RestDescription, RestResource};
/// Builds the full CLI command tree from a Discovery Document.
pub fn build_cli(doc: &RestDescription) -> Command {
let about_text = doc
.description
.clone()
.unwrap_or_else(|| "Google Workspace CLI".to_string());
let mut root = Command::new("gws")
.about(about_text)
.subcommand_required(true)
.arg_required_else_help(true)
.arg(
clap::Arg::new("sanitize")
.long("sanitize")
.help("Sanitize API responses through a Model Armor template. Requires cloud-platform scope. Format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE. Also reads GWS_SANITIZE_TEMPLATE env var.")
.value_name("TEMPLATE")
.global(true),
)
.arg(
clap::Arg::new("dry-run")
.long("dry-run")
.help("Validate the request locally without sending it to the API")
.action(clap::ArgAction::SetTrue)
.global(true),
)
.arg(
clap::Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
);
// Inject helper commands
let helper = crate::helpers::get_helper(&doc.name);
if let Some(ref helper) = helper {
root = helper.inject_commands(root, doc);
}
// Add resource subcommands (unless helper suppresses them)
let skip_resources = helper.as_ref().is_some_and(|h| h.helper_only());
if !skip_resources {
let mut resource_names: Vec<_> = doc.resources.keys().collect();
resource_names.sort();
for name in resource_names {
let resource = &doc.resources[name];
if let Some(cmd) = build_resource_command(name, resource) {
root = root.subcommand(cmd);
}
}
}
root
}
/// Recursively builds a Command for a resource.
/// Returns None if the resource has no methods or sub-resources.
fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command> {
let mut cmd = Command::new(name.to_string())
.about(format!("Operations on the '{name}' resource"))
.subcommand_required(true)
.arg_required_else_help(true);
let mut has_children = false;
// Add method subcommands
let mut method_names: Vec<_> = resource.methods.keys().collect();
method_names.sort();
for method_name in method_names {
let method = &resource.methods[method_name];
has_children = true;
let about = crate::text::truncate_description(
method.description.as_deref().unwrap_or(""),
crate::text::CLI_DESCRIPTION_LIMIT,
true,
);
let mut method_cmd = Command::new(method_name.to_string())
.about(about)
.arg(
Arg::new("params")
.long("params")
.help("JSON string for URL/Query parameters")
.value_name("JSON"),
)
.arg(
Arg::new("output")
.long("output")
.short('o')
.help("Output file path for binary responses")
.value_name("PATH"),
);
// Only add --json flag if the method accepts a request body
if method.request.is_some() {
method_cmd = method_cmd.arg(
Arg::new("json")
.long("json")
.help("JSON string for the request body")
.value_name("JSON"),
);
}
// Add --upload flag if the method supports media upload
if method.supports_media_upload {
method_cmd = method_cmd
.arg(
Arg::new("upload")
.long("upload")
.help("Local file path to upload as media content (multipart upload)")
.value_name("PATH"),
)
.arg(
Arg::new("upload-content-type")
.long("upload-content-type")
.help("MIME type of the uploaded file content (e.g. text/markdown). If omitted, detected from file extension or metadata mimeType")
.value_name("MIME"),
);
}
// Pagination flags
method_cmd = method_cmd
.arg(
Arg::new("page-all")
.long("page-all")
.help("Auto-paginate through all results, outputting one JSON line per page (NDJSON)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("page-limit")
.long("page-limit")
.help("Maximum number of pages to fetch when using --page-all (default: 10)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("page-delay")
.long("page-delay")
.help("Delay in milliseconds between page fetches (default: 100)")
.value_name("MS")
.value_parser(clap::value_parser!(u64)),
);
cmd = cmd.subcommand(method_cmd);
}
// Add sub-resource subcommands (recursive)
let mut sub_names: Vec<_> = resource.resources.keys().collect();
sub_names.sort();
for sub_name in sub_names {
let sub_resource = &resource.resources[sub_name];
if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) {
has_children = true;
cmd = cmd.subcommand(sub_cmd);
}
}
if has_children {
Some(cmd)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestMethod, RestResource};
use std::collections::HashMap;
fn make_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"list".to_string(),
RestMethod {
id: None,
description: None,
http_method: "GET".to_string(),
path: "list".to_string(),
parameters: HashMap::new(),
parameter_order: vec![],
request: None,
response: None,
scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()],
flat_path: None,
supports_media_download: false,
supports_media_upload: false,
media_upload: None,
},
);
methods.insert(
"delete".to_string(),
RestMethod {
id: None,
description: None,
http_method: "DELETE".to_string(),
path: "delete".to_string(),
parameters: HashMap::new(),
parameter_order: vec![],
request: None,
response: None,
scopes: vec!["https://www.googleapis.com/auth/drive".to_string()],
flat_path: None,
supports_media_download: false,
supports_media_upload: false,
media_upload: None,
},
);
let mut resources = HashMap::new();
resources.insert(
"files".to_string(),
RestResource {
methods,
resources: HashMap::new(),
},
);
RestDescription {
name: "drive".to_string(),
version: "v3".to_string(),
title: None,
description: None,
root_url: "".to_string(),
service_path: "".to_string(),
base_url: None,
schemas: HashMap::new(),
resources,
parameters: HashMap::new(),
auth: None,
}
}
#[test]
fn test_all_commands_always_shown() {
let doc = make_doc();
let cmd = build_cli(&doc);
// Should have "files" subcommand
let files_cmd = cmd
.find_subcommand("files")
.expect("files resource missing");
// All methods should always be visible regardless of auth state
assert!(files_cmd.find_subcommand("list").is_some());
assert!(files_cmd.find_subcommand("delete").is_some());
}
#[test]
fn test_sanitize_arg_present() {
let doc = make_doc();
let cmd = build_cli(&doc);
// The --sanitize global arg should be available
let args: Vec<_> = cmd.get_arguments().collect();
let sanitize_arg = args.iter().find(|a| a.get_id() == "sanitize");
assert!(
sanitize_arg.is_some(),
"--sanitize arg should be present on root command"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Discovery Document types and fetching.
//!
//! Types are re-exported from the `google_workspace` library crate.
//! The CLI wrapper provides default caching via `config_dir()`.
pub use google_workspace::discovery::*;
/// Fetches and caches a Google Discovery Document using the CLI's config directory.
///
/// This is a convenience wrapper around
/// [`google_workspace::discovery::fetch_discovery_document`] that automatically
/// uses the CLI's cache directory (`~/.config/gws/cache/`).
pub async fn fetch_discovery_document(
service: &str,
version: &str,
) -> anyhow::Result<RestDescription> {
let cache_dir = crate::auth_commands::config_dir().join("cache");
google_workspace::discovery::fetch_discovery_document(service, version, Some(&cache_dir)).await
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Structured error types and CLI error output.
//!
//! Core error types are re-exported from the `google_workspace` library crate.
//! CLI-specific error formatting (colored terminal output) is defined here.
pub use google_workspace::error::*;
use crate::output::{colorize, sanitize_for_terminal};
/// Human-readable exit code table, keyed by (code, description).
///
/// Used by `print_usage()` so the help text stays in sync with the
/// constants defined below without requiring manual updates in two places.
pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[
(0, "Success"),
(
GwsError::EXIT_CODE_API,
"API error — Google returned an error response",
),
(
GwsError::EXIT_CODE_AUTH,
"Auth error — credentials missing or invalid",
),
(
GwsError::EXIT_CODE_VALIDATION,
"Validation — bad arguments or input",
),
(
GwsError::EXIT_CODE_DISCOVERY,
"Discovery — could not fetch API schema",
),
(GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"),
];
/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
match err {
GwsError::Api { .. } => colorize("error[api]:", "31"), // red
GwsError::Auth(_) => colorize("error[auth]:", "31"), // red
GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow
GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red
GwsError::Other(_) => colorize("error:", "31"), // red
}
}
/// Formats any error as a JSON object and prints to stdout.
///
/// A human-readable colored label is printed to stderr when connected to a
/// TTY. For `accessNotConfigured` errors (HTTP 403, reason
/// `accessNotConfigured`), additional guidance is printed to stderr.
/// The JSON output on stdout is unchanged (machine-readable).
pub fn print_error_json(err: &GwsError) {
let json = err.to_json();
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
// Print a colored summary to stderr. For accessNotConfigured errors,
// print specialized guidance instead of the generic message to avoid
// redundant output (the full API error already appears in the JSON).
if let GwsError::Api {
reason, enable_url, ..
} = err
{
if reason == "accessNotConfigured" {
eprintln!();
let hint = colorize("hint:", "36"); // cyan
eprintln!(
"{} {hint} API not enabled for your GCP project.",
error_label(err)
);
if let Some(url) = enable_url {
eprintln!(" Enable it at: {url}");
} else {
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
}
eprintln!(" After enabling, wait a few seconds and retry your command.");
return;
}
}
eprintln!(
"{} {}",
error_label(err),
sanitize_for_terminal(&err.to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial]
fn test_colorize_respects_no_color_env() {
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
}
#[test]
fn test_error_label_contains_variant_name() {
let api_err = GwsError::Api {
code: 400,
message: "bad".to_string(),
reason: "r".to_string(),
enable_url: None,
};
let label = error_label(&api_err);
assert!(label.contains("error[api]:"));
let auth_err = GwsError::Auth("fail".to_string());
assert!(error_label(&auth_err).contains("error[auth]:"));
let val_err = GwsError::Validation("bad input".to_string());
assert!(error_label(&val_err).contains("error[validation]:"));
let disc_err = GwsError::Discovery("missing".to_string());
assert!(error_label(&disc_err).contains("error[discovery]:"));
let other_err = GwsError::Other(anyhow::anyhow!("oops"));
assert!(error_label(&other_err).contains("error:"));
}
#[test]
fn test_sanitize_for_terminal_strips_control_chars() {
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
let input2 = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab");
let input3 = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Output Formatting
//!
//! Transforms JSON API responses into human-readable formats (table, YAML, CSV).
use serde_json::Value;
use std::fmt::Write;
/// Supported output formats.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum OutputFormat {
/// Pretty-printed JSON (default).
#[default]
Json,
/// Aligned text table.
Table,
/// YAML.
Yaml,
/// Comma-separated values.
Csv,
}
impl OutputFormat {
/// Parse from a string argument.
///
/// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the
/// string is not recognised. Call sites should warn the user on `Err` and
/// decide whether to fall back to JSON or surface an error.
pub fn parse(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"table" => Ok(Self::Table),
"yaml" | "yml" => Ok(Self::Yaml),
"csv" => Ok(Self::Csv),
other => Err(other.to_string()),
}
}
/// Parse from a string argument, falling back to JSON for unknown values.
///
/// Prefer `parse()` at call sites where you want to surface a warning.
pub fn from_str(s: &str) -> Self {
Self::parse(s).unwrap_or(Self::Json)
}
}
/// Format a JSON value according to the specified output format.
pub fn format_value(value: &Value, format: &OutputFormat) -> String {
match format {
OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(),
OutputFormat::Table => format_table(value),
OutputFormat::Yaml => format_yaml(value),
OutputFormat::Csv => format_csv(value),
}
}
/// Format a JSON value for a paginated page.
///
/// When auto-paginating with `--page-all`, CSV and table formats should only
/// emit column headers on the **first** page so that each subsequent page
/// contains only data rows, making the combined output machine-parseable.
///
/// For JSON the output is compact (one JSON object per line / NDJSON).
/// For YAML each page is prefixed with a `---` document separator so the
/// combined stream is a valid YAML multi-document file.
pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String {
match format {
OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(),
OutputFormat::Csv => format_csv_page(value, is_first_page),
OutputFormat::Table => format_table_page(value, is_first_page),
// Prefix every page with a YAML document separator so that the
// concatenated stream is parseable as a multi-document YAML file.
OutputFormat::Yaml => format!("---\n{}", format_yaml(value)),
}
}
/// Extract a "data array" from a typical Google API list response.
/// Google APIs return lists as `{ "files": [...], "nextPageToken": "..." }`
/// where the array key varies by resource type.
fn extract_items(value: &Value) -> Option<(&str, &Vec<Value>)> {
if let Value::Object(obj) = value {
for (key, val) in obj {
if key == "nextPageToken" || key == "kind" || key.starts_with('_') {
continue;
}
if let Value::Array(arr) = val {
if !arr.is_empty() {
return Some((key, arr));
}
}
}
}
None
}
fn format_table(value: &Value) -> String {
format_table_page(value, true)
}
/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs.
///
/// Nested objects become `parent.child` key names so that `--format table` can
/// render them as individual columns instead of raw JSON blobs.
fn flatten_object(obj: &serde_json::Map<String, Value>, prefix: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (key, val) in obj {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match val {
Value::Object(nested) => {
out.extend(flatten_object(nested, &full_key));
}
_ => {
out.push((full_key, value_to_cell(val)));
}
}
}
out
}
/// Format as a text table, optionally omitting the header row.
///
/// Pass `emit_header = false` for continuation pages when using `--page-all`
/// so the combined terminal output doesn't repeat column names and separator
/// lines between pages.
fn format_table_page(value: &Value, emit_header: bool) -> String {
// Try to extract a list of items from standard Google API response
let items = extract_items(value);
if let Some((_key, arr)) = items {
format_array_as_table(arr, emit_header)
} else if let Value::Array(arr) = value {
format_array_as_table(arr, emit_header)
} else if let Value::Object(obj) = value {
// Single object: key/value table — flatten nested objects first
let mut output = String::new();
let flat = flatten_object(obj, "");
let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, val_str) in &flat {
let _ = writeln!(output, "{:width$} {}", key, val_str, width = max_key_len);
}
output
} else {
value.to_string()
}
}
fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
if arr.is_empty() {
return "(empty)\n".to_string();
}
// Flatten each row so nested objects become dot-notation columns.
let flat_rows: Vec<Vec<(String, String)>> = arr
.iter()
.map(|item| match item {
Value::Object(obj) => flatten_object(obj, ""),
_ => vec![(String::new(), value_to_cell(item))],
})
.collect();
// Collect all unique column names (preserving insertion order).
let mut columns: Vec<String> = Vec::new();
for row in &flat_rows {
for (key, _) in row {
if !columns.contains(key) {
columns.push(key.clone());
}
}
}
if columns.is_empty() {
// Array of non-objects
let mut output = String::new();
for item in arr {
let _ = writeln!(output, "{}", value_to_cell(item));
}
return output;
}
// Build lookup: row_index -> column_name -> cell_value
let row_maps: Vec<std::collections::HashMap<&str, &str>> = flat_rows
.iter()
.map(|pairs| {
pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
})
.collect();
// Calculate column widths (char-count, not byte-count).
let mut widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
let rows: Vec<Vec<String>> = row_maps
.iter()
.map(|row| {
columns
.iter()
.enumerate()
.map(|(i, col)| {
let cell = row.get(col.as_str()).copied().unwrap_or("").to_string();
let char_len = cell.chars().count();
if char_len > widths[i] {
widths[i] = char_len;
}
// Cap column width at 60 chars
if widths[i] > 60 {
widths[i] = 60;
}
cell
})
.collect()
})
.collect();
let mut output = String::new();
if emit_header {
// Header
let header: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, c)| format!("{:width$}", c, width = widths[i]))
.collect();
let _ = writeln!(output, "{}", header.join(" "));
// Separator
let sep: Vec<String> = widths.iter().map(|w| "".repeat(*w)).collect();
let _ = writeln!(output, "{}", sep.join(" "));
}
// Rows — truncate by char count to avoid panicking on multi-byte UTF-8.
for row in &rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(i, c)| {
let char_len = c.chars().count();
let truncated = if char_len > widths[i] {
// Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis.
let truncated_str: String = c.chars().take(widths[i] - 1).collect();
format!("{truncated_str}")
} else {
c.clone()
};
// Pad to column width (by char count)
let pad = widths[i].saturating_sub(truncated.chars().count());
format!("{truncated}{}", " ".repeat(pad))
})
.collect();
let _ = writeln!(output, "{}", cells.join(" "));
}
output
}
fn format_yaml(value: &Value) -> String {
json_to_yaml(value, 0)
}
fn json_to_yaml(value: &Value, indent: usize) -> String {
let prefix = " ".repeat(indent);
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
if s.contains('\n') {
// Genuine multi-line content: block scalar is the most readable choice.
format!(
"|\n{}",
s.lines()
.map(|l| format!("{prefix} {l}"))
.collect::<Vec<_>>()
.join("\n")
)
} else {
// Single-line strings: always double-quote so that characters like
// `#` (comment marker) and `:` (mapping indicator) are never
// misinterpreted by YAML parsers. Escape backslashes and double
// quotes to keep the output valid.
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
}
Value::Array(arr) => {
if arr.is_empty() {
return "[]".to_string();
}
let mut out = String::new();
for item in arr {
let val_str = json_to_yaml(item, indent + 1);
let _ = write!(out, "\n{prefix}- {val_str}");
}
out
}
Value::Object(obj) => {
if obj.is_empty() {
return "{}".to_string();
}
let mut out = String::new();
for (key, val) in obj {
match val {
Value::Object(_) | Value::Array(_) => {
let val_str = json_to_yaml(val, indent + 1);
let _ = write!(out, "\n{prefix}{key}:{val_str}");
}
_ => {
let val_str = json_to_yaml(val, indent);
let _ = write!(out, "\n{prefix}{key}: {val_str}");
}
}
}
out
}
}
}
fn format_csv(value: &Value) -> String {
format_csv_page(value, true)
}
/// Format as CSV, optionally omitting the header row.
///
/// Pass `emit_header = false` for all pages after the first when using
/// `--page-all`, so the combined output has a single header line.
fn format_csv_page(value: &Value, emit_header: bool) -> String {
let items = extract_items(value);
let arr = if let Some((_key, arr)) = items {
arr.as_slice()
} else if let Value::Array(arr) = value {
arr.as_slice()
} else {
// Single value — just output it
return value_to_cell(value);
};
if arr.is_empty() {
return String::new();
}
// Array of non-objects
if !arr.iter().any(|v| v.is_object()) {
let mut output = String::new();
for item in arr {
if let Value::Array(inner) = item {
let cells: Vec<String> = inner
.iter()
.map(|v| csv_escape(&value_to_cell(v)))
.collect();
let _ = writeln!(output, "{}", cells.join(","));
} else {
let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item)));
}
}
return output;
}
// Collect columns
let mut columns: Vec<String> = Vec::new();
for item in arr {
if let Value::Object(obj) = item {
for key in obj.keys() {
if !columns.contains(key) {
columns.push(key.clone());
}
}
}
}
let mut output = String::new();
// Header (omitted on continuation pages)
if emit_header {
let _ = writeln!(output, "{}", columns.join(","));
}
// Rows
for item in arr {
let cells: Vec<String> = columns
.iter()
.map(|col| {
if let Value::Object(obj) = item {
csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null)))
} else {
String::new()
}
})
.collect();
let _ = writeln!(output, "{}", cells.join(","));
}
output
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn value_to_cell(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(value_to_cell).collect();
items.join(", ")
}
Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_output_format_from_str() {
assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json);
assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table);
assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml);
assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml);
assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv);
assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json);
}
#[test]
fn test_output_format_parse_known() {
assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json));
assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table));
assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml));
assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml));
assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv));
// Case-insensitive
assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json));
assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table));
}
#[test]
fn test_output_format_parse_unknown_returns_err() {
assert!(OutputFormat::parse("bogus").is_err());
assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus");
assert!(OutputFormat::parse("").is_err());
}
#[test]
fn test_format_json() {
let val = json!({"name": "test"});
let output = format_value(&val, &OutputFormat::Json);
assert!(output.contains("\"name\""));
assert!(output.contains("\"test\""));
}
#[test]
fn test_format_table_array_of_objects() {
let val = json!({
"files": [
{"id": "1", "name": "hello.txt"},
{"id": "2", "name": "world.txt"}
]
});
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("id"));
assert!(output.contains("name"));
assert!(output.contains("hello.txt"));
assert!(output.contains("world.txt"));
// Check separator line
assert!(output.contains("──"));
}
#[test]
fn test_format_table_single_object() {
let val = json!({"id": "abc", "name": "test"});
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("id"));
assert!(output.contains("abc"));
}
#[test]
fn test_format_table_nested_object_flattened() {
// Nested objects should become dot-notation columns, not raw JSON blobs.
let val = json!({
"user": {
"displayName": "Alice",
"emailAddress": "alice@example.com"
},
"storageQuota": {
"limit": "1000",
"usage": "500"
}
});
let output = format_value(&val, &OutputFormat::Table);
// Should contain dot-notation keys
assert!(
output.contains("user.displayName"),
"expected flattened key in output:\n{output}"
);
assert!(
output.contains("user.emailAddress"),
"expected flattened key in output:\n{output}"
);
assert!(
output.contains("Alice"),
"expected value in output:\n{output}"
);
// Should NOT contain raw JSON blobs
assert!(
!output.contains("{\"displayName"),
"should not have raw JSON blob:\n{output}"
);
}
#[test]
fn test_format_table_nested_objects_in_array() {
let val = json!([
{"id": "1", "owner": {"name": "Alice"}},
{"id": "2", "owner": {"name": "Bob"}}
]);
let output = format_value(&val, &OutputFormat::Table);
assert!(
output.contains("owner.name"),
"expected flattened column:\n{output}"
);
assert!(output.contains("Alice"), "expected value:\n{output}");
assert!(output.contains("Bob"), "expected value:\n{output}");
}
#[test]
fn test_format_table_multibyte_truncation_does_not_panic() {
// Column width cap is 60 chars, so a long string with multi-byte chars
// must be safely truncated without a byte-boundary panic.
let long_emoji = "😀".repeat(70); // each emoji is 4 bytes
let val = json!([{"col": long_emoji}]);
// Should not panic
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("col"), "column name must appear:\n{output}");
}
#[test]
fn test_format_table_multibyte_exact_boundary() {
// Multi-byte chars at various positions must not panic or produce garbled output.
let val = json!([{"name": "café résumé naïve"}]);
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("name"), "column must appear:\n{output}");
}
#[test]
fn test_format_csv() {
let val = json!({
"files": [
{"id": "1", "name": "hello"},
{"id": "2", "name": "world"}
]
});
let output = format_value(&val, &OutputFormat::Csv);
assert!(output.contains("id,name"));
assert!(output.contains("1,hello"));
assert!(output.contains("2,world"));
}
#[test]
fn test_format_csv_array_of_arrays() {
// Sheets API returns {"values": [["col1","col2"], ["a","b"]]}
let val = json!({
"values": [
["Student Name", "Gender", "Class Level"],
["Alexandra", "Female", "4. Senior"],
["Andrew", "Male", "1. Freshman"]
]
});
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "Student Name,Gender,Class Level");
assert_eq!(lines[1], "Alexandra,Female,4. Senior");
assert_eq!(lines[2], "Andrew,Male,1. Freshman");
}
#[test]
fn test_format_csv_flat_scalars() {
// Flat array of non-object, non-array values → one value per line
let val = json!(["apple", "banana", "cherry"]);
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "apple");
assert_eq!(lines[1], "banana");
assert_eq!(lines[2], "cherry");
}
#[test]
fn test_format_csv_flat_scalars_with_escaping() {
// Scalars that contain commas/quotes must be CSV-escaped
let val = json!(["plain", "has,comma", "has\"quote"]);
let output = format_value(&val, &OutputFormat::Csv);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "plain");
assert_eq!(lines[1], "\"has,comma\"");
assert_eq!(lines[2], "\"has\"\"quote\"");
}
#[test]
fn test_format_csv_escape() {
assert_eq!(csv_escape("simple"), "simple");
assert_eq!(csv_escape("has,comma"), "\"has,comma\"");
assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\"");
}
#[test]
fn test_format_yaml() {
let val = json!({"name": "test", "count": 42});
let output = format_value(&val, &OutputFormat::Yaml);
assert!(output.contains("name: \"test\""));
assert!(output.contains("count: 42"));
}
#[test]
fn test_format_table_empty_array() {
let val = json!({"files": []});
// No items to extract, falls back to single-object table
let output = format_value(&val, &OutputFormat::Table);
assert!(output.contains("files"));
}
#[test]
fn test_extract_items() {
let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"});
let (key, items) = extract_items(&val).unwrap();
assert_eq!(key, "files");
assert_eq!(items.len(), 1);
}
#[test]
fn test_extract_items_none() {
let val = json!({"status": "ok"});
assert!(extract_items(&val).is_none());
}
// --- YAML block-scalar regression tests ---
#[test]
fn test_format_yaml_hash_in_string_is_quoted_not_block() {
// `drive#file` contains `#` which is a YAML comment marker; the
// serialiser must quote it rather than emit a block scalar.
let val = json!({"kind": "drive#file", "id": "123"});
let output = format_value(&val, &OutputFormat::Yaml);
// Must be a double-quoted string, not a block scalar (`|`).
assert!(
output.contains("kind: \"drive#file\""),
"expected double-quoted kind, got:\n{output}"
);
assert!(
!output.contains("kind: |"),
"kind must not use block scalar, got:\n{output}"
);
}
#[test]
fn test_format_yaml_colon_in_string_is_quoted() {
let val = json!({"url": "https://example.com/path"});
let output = format_value(&val, &OutputFormat::Yaml);
assert!(
output.contains("url: \"https://example.com/path\""),
"expected double-quoted url, got:\n{output}"
);
assert!(!output.contains("url: |"), "url must not use block scalar");
}
#[test]
fn test_format_yaml_multiline_still_uses_block() {
let val = json!({"body": "line one\nline two"});
let output = format_value(&val, &OutputFormat::Yaml);
// Multi-line content should still use block scalar.
assert!(
output.contains("body: |"),
"multiline string must use block scalar, got:\n{output}"
);
}
// --- Paginated format tests ---
#[test]
fn test_format_value_paginated_csv_first_page_has_header() {
let val = json!({
"files": [
{"id": "1", "name": "a.txt"},
{"id": "2", "name": "b.txt"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Csv, true);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "id,name", "first page must start with header");
assert_eq!(lines[1], "1,a.txt");
}
#[test]
fn test_format_value_paginated_csv_continuation_no_header() {
let val = json!({
"files": [
{"id": "3", "name": "c.txt"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Csv, false);
let lines: Vec<&str> = output.lines().collect();
// The first (and only) line must be a data row, not the header.
assert_eq!(lines[0], "3,c.txt", "continuation page must have no header");
assert!(
!output.contains("id,name"),
"header must be absent on continuation pages"
);
}
#[test]
fn test_format_value_paginated_table_first_page_has_header() {
let val = json!({
"items": [
{"id": "1", "name": "foo"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Table, true);
assert!(
output.contains("id"),
"table header must appear on first page"
);
assert!(output.contains("──"), "separator must appear on first page");
}
#[test]
fn test_format_value_paginated_table_continuation_no_header() {
let val = json!({
"items": [
{"id": "2", "name": "bar"}
]
});
let output = format_value_paginated(&val, &OutputFormat::Table, false);
assert!(output.contains("bar"), "data row must be present");
assert!(
!output.contains("──"),
"separator must be absent on continuation pages"
);
}
#[test]
fn test_format_value_paginated_yaml_has_document_separator() {
let val = json!({"files": [{"id": "1", "name": "foo"}]});
let first = format_value_paginated(&val, &OutputFormat::Yaml, true);
let second = format_value_paginated(&val, &OutputFormat::Yaml, false);
assert!(
first.starts_with("---\n"),
"first YAML page must start with ---"
);
assert!(
second.starts_with("---\n"),
"continuation YAML pages must also start with ---"
);
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! File-system utilities.
use std::io::{self, Write};
use std::path::Path;
/// Write `data` to `path` atomically.
///
/// This implementation uses `tempfile::NamedTempFile` to create a temporary
/// file with a random name, `O_EXCL` flags (preventing symlink attacks),
/// and secure 0600 permissions from the moment of creation.
///
/// # Errors
///
/// Returns an `io::Error` if the temporary file cannot be created/written or if the
/// final rename fails.
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
})?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(data)?;
tmp.as_file().sync_all()?;
tmp.persist(path)
.map_err(|e| io::Error::new(e.error.kind(), e.error))?;
Ok(())
}
/// Async variant of [`atomic_write`] for use with tokio.
///
/// This implementation uses `create_new(true)` (O_EXCL) and `mode(0o600)` to
/// prevent TOCTOU/symlink race conditions.
pub async fn atomic_write_async(path: &Path, data: &[u8]) -> io::Result<()> {
use rand::Rng;
use tokio::io::AsyncWriteExt;
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
})?;
let file_name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
.to_string_lossy();
let mut retries = 0;
let mut file: tokio::fs::File;
let mut tmp_path;
loop {
let suffix: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(8)
.map(char::from)
.collect();
let tmp_name = format!("{}.tmp.{}", file_name, suffix);
tmp_path = parent.join(tmp_name);
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
opts.mode(0o600);
}
match opts.open(&tmp_path).await {
Ok(f) => {
file = f;
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && retries < 10 => {
retries += 1;
continue;
}
Err(e) => return Err(e),
}
}
let write_result = async {
file.write_all(data).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&tmp_path, path).await
}
.await;
if write_result.is_err() {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
write_result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
atomic_write(&path, b"hello").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}
#[test]
fn test_atomic_write_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
fs::write(&path, b"old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
}
#[test]
fn test_atomic_write_leaves_no_tmp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
atomic_write(&path, b"data").unwrap();
// Since we use random names, we just check that no .tmp files remain in the dir
let files: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|res| res.unwrap().file_name())
.collect();
assert_eq!(files.len(), 1);
assert_eq!(files[0], "credentials.enc");
}
#[tokio::test]
async fn test_atomic_write_async_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token_cache.json");
atomic_write_async(&path, b"async hello").await.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"async hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
# Helper Commands (`+verb`) — Guidelines
## Design Principle
The core design of `gws` is **schema-driven**: commands are dynamically generated from Google Discovery Documents at runtime. This avoids maintaining a hardcoded, unbounded argument surface. **Helpers must complement this design, not duplicate it.**
## When a Helper is Justified
A `+helper` command should exist only when it provides value that Discovery-based commands **cannot**:
| Justification | Example | Why Discovery Can't Do It |
|---|---|---|
| **Multi-step orchestration** | `+subscribe` | Creates Pub/Sub topic → subscription → Workspace Events subscription (3 APIs) |
| **Format translation** | `+write` | Transforms Markdown → Docs `batchUpdate` JSON |
| **Multi-API composition** | `+triage` | Lists messages then fetches N metadata payloads concurrently |
| **Complex body construction** | `+send`, `+reply` | Builds RFC 2822 MIME from simple flags |
| **Multipart upload** | `+upload` | Handles resumable upload protocol with progress |
| **Workflow recipes** | `+standup-report` | Chains calls across multiple services |
**Litmus test:** Can the user achieve the same result with `gws <service> <resource> <method> --params '{...}'`? If yes, don't add a helper.
## Anti-Patterns
### ❌ Anti-pattern 1: Single API Call Wrapper
If a helper wraps one API call that Discovery already exposes, reject it.
**Real example:** `+revisions` (PR #563) wrapped `gws drive files-revisions list` — same single API call, zero added value.
### ❌ Anti-pattern 2: Unbounded Flag Accumulation
Adding flags to expose data that is already in the API response creates unbounded surface area.
**Real example:** `--thread-id`, `--delivered-to`, `--sent-last` on `+triage` (PR #597) — all three values are already present in the Gmail API response. Agents and users should extract them with `--format` or `jq`, not new flags.
**Why this is harmful:** Every API response contains dozens of fields. If we add a flag for each one, helpers become unbounded maintenance burdens — the exact problem Discovery-driven design solves.
### ❌ Anti-pattern 3: Duplicating Discovery Parameters
Don't re-expose Discovery-defined parameters (e.g., `pageSize`, `fields`, `orderBy`) as custom helper flags. Use `--params` passthrough instead.
## Flag Design Rules
Helper flags must control **orchestration logic**, not API parameters or output fields.
### ✅ Good Flags (control orchestration)
| Flag | Helper | Why It's Good |
|---|---|---|
| `--spreadsheet`, `--range` | `+read` | Identifies which resource to operate on |
| `--to`, `--subject`, `--body` | `+send` | Inputs to MIME construction (format translation) |
| `--dry-run` | `+subscribe` | Controls whether API calls are actually made |
| `--subscription` | `+subscribe` | Switches between "create new" vs. "use existing" orchestration path |
| `--target`, `--project` | `+subscribe` | Required for multi-service resource creation |
### ❌ Bad Flags (expose API response data)
| Flag | Why It's Bad | Alternative |
|---|---|---|
| `--thread-id` | Already in API response | `jq '.threadId'` |
| `--delivered-to` | Already in response headers | `jq '.payload.headers[] | ...'` |
| `--include-labels` | Output field filtering | `--format` or `jq` |
### Decision Checklist for New Flags
1. Does this flag control **what API call to make** or **how to orchestrate** multiple calls? → ✅ Add it
2. Does this flag control **what data appears in output**? → ❌ Use `--format`/`jq`
3. Does this flag duplicate a Discovery parameter? → ❌ Use `--params`
4. Could the user achieve this with existing flags + post-processing? → ❌ Don't add it
## Architecture
Helpers are implemented using the `Helper` trait defined in `mod.rs`.
- **`inject_commands`**: Adds subcommands to the main service command. All helper commands are always shown regardless of authentication state.
- **`handle`**: Implementation of the command logic. Returns `Ok(true)` if the command was handled, or `Ok(false)` to let the default raw resource handler attempt to handle it.
## Adding a New Helper — Checklist
1. **Passes the litmus test** — cannot be done with a single Discovery command
2. **Flags are bounded** — only flags controlling orchestration, not API params/output
3. **Uses shared infrastructure:**
- `crate::client::build_client()` for HTTP
- `crate::validate::validate_resource_name()` for user-supplied resource IDs
- `crate::validate::encode_path_segment()` for URL path segments
- `crate::output::sanitize_for_terminal()` for error messages
4. **Has tests** — at minimum: command registration, required args, happy path
5. **Supports `--dry-run`** where the helper creates or mutates resources
### Development Steps
1. Create `src/helpers/<service>.rs`
2. Implement the `Helper` trait
3. Register it in `src/helpers/mod.rs`
4. **Prefix** the command with `+` (e.g., `+create`)
@@ -0,0 +1,772 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::json;
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
pub struct CalendarHelper;
impl Helper for CalendarHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+insert")
.about("[Helper] create a new event")
.arg(
Arg::new("calendar")
.long("calendar")
.help("Calendar ID (default: primary)")
.default_value("primary")
.value_name("ID"),
)
.arg(
Arg::new("summary")
.long("summary")
.help("Event summary/title")
.required(true)
.value_name("TEXT"),
)
.arg(
Arg::new("start")
.long("start")
.help("Start time (ISO 8601, e.g., 2024-01-01T10:00:00Z)")
.required(true)
.value_name("TIME"),
)
.arg(
Arg::new("end")
.long("end")
.help("End time (ISO 8601)")
.required(true)
.value_name("TIME"),
)
.arg(
Arg::new("location")
.long("location")
.help("Event location")
.value_name("TEXT"),
)
.arg(
Arg::new("description")
.long("description")
.help("Event description/body")
.value_name("TEXT"),
)
.arg(
Arg::new("attendee")
.long("attendee")
.help("Attendee email (can be used multiple times)")
.value_name("EMAIL")
.action(ArgAction::Append),
)
.arg(
Arg::new("meet")
.long("meet")
.help("Add a Google Meet video conference link")
.action(ArgAction::SetTrue),
)
.after_help("\
EXAMPLES:
gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00'
gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com
gws calendar +insert --summary 'Meet' --start ... --end ... --meet
TIPS:
Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
The --meet flag automatically adds a Google Meet link to the event."),
);
cmd = cmd.subcommand(
Command::new("+agenda")
.about("[Helper] Show upcoming events across all calendars")
.arg(
Arg::new("today")
.long("today")
.help("Show today's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("tomorrow")
.long("tomorrow")
.help("Show tomorrow's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("week")
.long("week")
.help("Show this week's events")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("days")
.long("days")
.help("Number of days ahead to show")
.value_name("N"),
)
.arg(
Arg::new("calendar")
.long("calendar")
.help("Filter to specific calendar name or ID")
.value_name("NAME"),
)
.arg(
Arg::new("timezone")
.long("timezone")
.alias("tz")
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
.value_name("TZ"),
)
.after_help(
"\
EXAMPLES:
gws calendar +agenda
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York
TIPS:
Read-only — never modifies events.
Queries all calendars by default; use --calendar to filter.
Uses your Google account timezone by default; override with --timezone.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+insert") {
let (params_str, body_str, scopes) = build_insert_request(matches, doc)?;
let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes_str).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Calendar auth failed: {e}"))),
};
let events_res = doc.resources.get("events").ok_or_else(|| {
GwsError::Discovery("Resource 'events' not found".to_string())
})?;
let insert_method = events_res.methods.get("insert").ok_or_else(|| {
GwsError::Discovery("Method 'events.insert' not found".to_string())
})?;
executor::execute_method(
doc,
insert_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+agenda") {
handle_agenda(matches).await?;
return Ok(true);
}
Ok(false)
})
}
}
async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let token = auth::get_token(&[cal_scope])
.await
.map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?;
let output_format = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
let client = crate::client::build_client()?;
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;
// Determine time range using the account timezone so that --today and
// --tomorrow align with the user's Google account day, not the machine.
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let days: i64 = if matches.get_flag("tomorrow") {
1
} else if matches.get_flag("week") {
7
} else {
matches
.get_one::<String>("days")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(1)
};
let (time_min_dt, time_max_dt) = if matches.get_flag("today") {
// Today: account tz midnight to midnight+1
let end = today_start_tz + chrono::Duration::days(1);
(today_start_tz, end)
} else if matches.get_flag("tomorrow") {
// Tomorrow: account tz midnight+1 to midnight+2
let start = today_start_tz + chrono::Duration::days(1);
let end = today_start_tz + chrono::Duration::days(2);
(start, end)
} else {
// From now, N days ahead
let end = now_in_tz + chrono::Duration::days(days);
(now_in_tz, end)
};
let time_min = time_min_dt.to_rfc3339();
let time_max = time_max_dt.to_rfc3339();
// client already built above for timezone resolution
let calendar_filter = matches.get_one::<String>("calendar");
// 1. List all calendars
let list_url = "https://www.googleapis.com/calendar/v3/users/me/calendarList";
let list_resp = client
.get(list_url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list calendars: {e}")))?;
if !list_resp.status().is_success() {
let err = list_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 0,
message: err,
reason: "calendarList_failed".to_string(),
enable_url: None,
});
}
let list_json: Value = list_resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse calendar list: {e}")))?;
let calendars = list_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
// 2. For each calendar, fetch events concurrently
use futures_util::stream::{self, StreamExt};
// Pre-filter calendars and collect owned data to avoid lifetime issues
struct CalInfo {
id: String,
summary: String,
}
let filtered_calendars: Vec<CalInfo> = calendars
.iter()
.filter_map(|cal| {
let cal_id = cal.get("id").and_then(|v| v.as_str()).unwrap_or("");
let cal_summary = cal
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or(cal_id);
// Apply calendar filter
if let Some(filter) = calendar_filter {
if !cal_summary.contains(filter.as_str()) && cal_id != filter.as_str() {
return None;
}
}
Some(CalInfo {
id: cal_id.to_string(),
summary: cal_summary.to_string(),
})
})
.collect();
let mut all_events: Vec<Value> = stream::iter(filtered_calendars)
.map(|cal| {
let client = &client;
let token = &token;
let time_min = &time_min;
let time_max = &time_max;
async move {
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(&cal.id),
);
let resp = crate::client::send_with_retry(|| {
client
.get(&events_url)
.query(&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
])
.bearer_auth(token)
})
.await;
let resp = match resp {
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
let events_json: Value = match resp.json().await {
Ok(v) => v,
Err(_) => return vec![],
};
let mut events = Vec::new();
if let Some(items) = events_json.get("items").and_then(|i| i.as_array()) {
for event in items {
let start = event
.get("start")
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let end = event
.get("end")
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let summary = event
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("(No title)")
.to_string();
let location = event
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
events.push(json!({
"start": start,
"end": end,
"summary": summary,
"calendar": cal.summary,
"location": location,
}));
}
}
events
}
})
.buffer_unordered(5)
.flat_map(stream::iter)
.collect()
.await;
// 3. Sort by start time
all_events.sort_by(|a, b| {
let a_start = a.get("start").and_then(|v| v.as_str()).unwrap_or("");
let b_start = b.get("start").and_then(|v| v.as_str()).unwrap_or("");
a_start.cmp(b_start)
});
let output = json!({
"events": all_events,
"count": all_events.len(),
"timeMin": time_min,
"timeMax": time_max,
});
println!(
"{}",
crate::formatter::format_value(&output, &output_format)
);
Ok(())
}
fn build_insert_request(
matches: &ArgMatches,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let calendar_id = matches.get_one::<String>("calendar").unwrap();
let summary = matches.get_one::<String>("summary").unwrap();
let start = matches.get_one::<String>("start").unwrap();
let end = matches.get_one::<String>("end").unwrap();
let location = matches.get_one::<String>("location");
let description = matches.get_one::<String>("description");
let attendees_vals = matches.get_many::<String>("attendee");
// Find method: events.insert checks
let events_res = doc
.resources
.get("events")
.ok_or_else(|| GwsError::Discovery("Resource 'events' not found".to_string()))?;
let insert_method = events_res
.methods
.get("insert")
.ok_or_else(|| GwsError::Discovery("Method 'events.insert' not found".to_string()))?;
// Build body
let mut body = json!({
"summary": summary,
"start": { "dateTime": start },
"end": { "dateTime": end },
});
if let Some(loc) = location {
body["location"] = json!(loc);
}
if let Some(desc) = description {
body["description"] = json!(desc);
}
if let Some(atts) = attendees_vals {
let attendees_list: Vec<_> = atts.map(|email| json!({ "email": email })).collect();
body["attendees"] = json!(attendees_list);
}
let mut params = json!({
"calendarId": calendar_id
});
if matches.get_flag("meet") {
let namespace = uuid::Uuid::NAMESPACE_DNS;
let mut attendees: Vec<_> = matches
.get_many::<String>("attendee")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
attendees.sort();
let seed_payload = {
let mut map = serde_json::Map::new();
map.insert("v".to_string(), json!(1));
map.insert("summary".to_string(), json!(summary));
map.insert("start".to_string(), json!(start));
map.insert("end".to_string(), json!(end));
if let Some(loc) = location {
map.insert("location".to_string(), json!(loc));
}
if let Some(desc) = description {
map.insert("description".to_string(), json!(desc));
}
if !attendees.is_empty() {
let attendees_list_for_seed: Vec<_> = attendees
.iter()
.map(|email| json!({ "email": email }))
.collect();
map.insert("attendees".to_string(), json!(attendees_list_for_seed));
}
serde_json::Value::Object(map)
};
let seed_data = serde_json::to_vec(&seed_payload).map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to serialize seed payload for idempotency key: {e}"
))
})?;
let request_id = uuid::Uuid::new_v5(&namespace, &seed_data).to_string();
body["conferenceData"] = json!({
"createRequest": {
"requestId": request_id,
"conferenceSolutionKey": { "type": "hangoutsMeet" }
}
});
params["conferenceDataVersion"] = json!(1);
}
let body_str = body.to_string();
let scopes: Vec<String> = insert_method.scopes.iter().map(|s| s.to_string()).collect();
// events.insert requires 'calendarId' path parameter
let params_str = params.to_string();
Ok((params_str, body_str, scopes))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_mock_doc() -> crate::discovery::RestDescription {
let mut doc = crate::discovery::RestDescription::default();
let mut events_res = crate::discovery::RestResource::default();
let mut insert_method = crate::discovery::RestMethod::default();
insert_method.scopes.push("https://scope".to_string());
events_res
.methods
.insert("insert".to_string(), insert_method);
doc.resources.insert("events".to_string(), events_res);
doc
}
fn make_matches_insert(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(
Arg::new("calendar")
.long("calendar")
.default_value("primary"),
)
.arg(Arg::new("summary").long("summary").required(true))
.arg(Arg::new("start").long("start").required(true))
.arg(Arg::new("end").long("end").required(true))
.arg(Arg::new("location").long("location"))
.arg(Arg::new("description").long("description"))
.arg(
Arg::new("attendee")
.long("attendee")
.action(ArgAction::Append),
)
.arg(Arg::new("meet").long("meet").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_insert_request() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
]);
let (params, body, scopes) = build_insert_request(&matches, &doc).unwrap();
assert!(params.contains("primary"));
assert!(body.contains("Meeting"));
assert!(body.contains("2024-01-01T10:00:00Z"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_build_insert_request_with_meet() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
]);
let (params, body, _) = build_insert_request(&matches, &doc).unwrap();
let params_json: serde_json::Value = serde_json::from_str(&params).unwrap();
assert_eq!(params_json["conferenceDataVersion"], 1);
let body_json: serde_json::Value = serde_json::from_str(&body).unwrap();
let create_req = &body_json["conferenceData"]["createRequest"];
assert_eq!(create_req["conferenceSolutionKey"]["type"], "hangoutsMeet");
assert!(uuid::Uuid::parse_str(create_req["requestId"].as_str().unwrap()).is_ok());
}
#[test]
fn test_build_insert_request_with_meet_is_idempotent() {
let doc = make_mock_doc();
let args = &[
"test",
"--summary",
"Idempotent Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
];
let matches1 = make_matches_insert(args);
let (_, body1, _) = build_insert_request(&matches1, &doc).unwrap();
let matches2 = make_matches_insert(args);
let (_, body2, _) = build_insert_request(&matches2, &doc).unwrap();
let b1: serde_json::Value = serde_json::from_str(&body1).unwrap();
let b2: serde_json::Value = serde_json::from_str(&body2).unwrap();
assert_eq!(
b1["conferenceData"]["createRequest"]["requestId"],
b2["conferenceData"]["createRequest"]["requestId"],
"requestId should be deterministic for the same event details"
);
}
#[test]
fn test_build_insert_request_with_meet_idempotency_robust() {
let doc = make_mock_doc();
// Base case
let args_base = &[
"test",
"--summary",
"S",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
];
let (_, body_base, _) =
build_insert_request(&make_matches_insert(args_base), &doc).unwrap();
let b_base: serde_json::Value = serde_json::from_str(&body_base).unwrap();
let id_base = b_base["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
// Same but different attendee order
let args_reordered = &[
"test",
"--summary",
"S",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"c@d.com",
"--attendee",
"a@b.com",
];
let (_, body_reordered, _) =
build_insert_request(&make_matches_insert(args_reordered), &doc).unwrap();
let b_reordered: serde_json::Value = serde_json::from_str(&body_reordered).unwrap();
let id_reordered = b_reordered["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
assert_eq!(
id_base, id_reordered,
"Attendee order should not change requestId"
);
// Different summary -> different ID
let args_diff = &[
"test",
"--summary",
"Diff",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
];
let (_, body_diff, _) =
build_insert_request(&make_matches_insert(args_diff), &doc).unwrap();
let b_diff: serde_json::Value = serde_json::from_str(&body_diff).unwrap();
let id_diff = b_diff["conferenceData"]["createRequest"]["requestId"]
.as_str()
.unwrap();
assert_ne!(
id_base, id_diff,
"Different summary should produce different requestId"
);
}
#[test]
fn test_build_insert_request_with_optional_fields() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--location",
"Room 1",
"--description",
"Discuss stuff",
"--attendee",
"a@b.com",
"--attendee",
"c@d.com",
]);
let (_, body, _) = build_insert_request(&matches, &doc).unwrap();
assert!(body.contains("Room 1"));
assert!(body.contains("Discuss stuff"));
assert!(body.contains("a@b.com"));
assert!(body.contains("c@d.com"));
}
/// Verify that agenda day boundaries use a specific timezone, not UTC.
#[test]
fn agenda_day_boundaries_use_account_timezone() {
use chrono::{NaiveTime, TimeZone, Utc};
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
let tz = chrono_tz::America::Denver;
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let today_start_tz = tz
.from_local_datetime(&today_start)
.earliest()
.expect("midnight should resolve");
let today_rfc = today_start_tz.to_rfc3339();
let tomorrow_start = today_start_tz + chrono::Duration::days(1);
let tomorrow_rfc = tomorrow_start.to_rfc3339();
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
// Crucially, it should NOT be +00:00 (UTC).
assert!(
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
"today boundary should carry Denver offset, got {today_rfc}"
);
assert!(
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
);
}
}
@@ -0,0 +1,282 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct ChatHelper;
impl Helper for ChatHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+send")
.about("[Helper] Send a message to a space")
.arg(
Arg::new("space")
.long("space")
.help("Space name (e.g. spaces/AAAA...)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Message text (plain text)")
.required(true)
.value_name("TEXT"),
)
.after_help(
"\
EXAMPLES:
gws chat +send --space spaces/AAAAxxxx --text 'Hello team!'
TIPS:
Use 'gws chat spaces list' to find space names.
For cards or threaded replies, use the raw API instead.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
// We use `Box::pin` to create a pinned future on the heap.
// This is necessary because the `Helper` trait returns a generic `Future`,
// and async blocks in Rust are anonymous types that need to be erased
// (via `dyn Future`) to be returned from a trait method.
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+send") {
// Parse arguments into our config struct config
let config = parse_send_args(matches)?;
// The `?` operator here will propagate any errors from `build_send_request`
// immediately, returning `Err(GwsError)` from the async block.
let (params_str, body_str, scopes) = build_send_request(&config, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Chat auth failed: {e}"))),
};
// Method: spaces.messages.create
let spaces_res = doc.resources.get("spaces").ok_or_else(|| {
GwsError::Discovery("Resource 'spaces' not found".to_string())
})?;
let messages_res = spaces_res.resources.get("messages").ok_or_else(|| {
GwsError::Discovery("Resource 'spaces.messages' not found".to_string())
})?;
let create_method = messages_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
create_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_send_request(
config: &SendConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let spaces_res = doc
.resources
.get("spaces")
.ok_or_else(|| GwsError::Discovery("Resource 'spaces' not found".to_string()))?;
let messages_res = spaces_res
.resources
.get("messages")
.ok_or_else(|| GwsError::Discovery("Resource 'spaces.messages' not found".to_string()))?;
let create_method = messages_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
})?;
let params = json!({
"parent": config.space
});
let body = json!({
"text": config.text
});
let scopes: Vec<String> = create_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), body.to_string(), scopes))
}
/// Configuration for sending a chat message.
///
/// This struct holds the parsed arguments for the `+send` command.
/// We use `String` here to own the data, as it will be used to construct
/// the JSON body for the API request.
pub struct SendConfig {
/// The space to send the message to (e.g., "spaces/AAAA...").
pub space: String,
/// The text content of the message.
pub text: String,
}
/// Parses the command line arguments into a `SendConfig` struct.
///
/// # Arguments
///
/// * `matches` - The `ArgMatches` from `clap` containing the parsed arguments.
///
/// # Returns
///
/// * `SendConfig` - The populated configuration struct.
pub fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
let space = matches.get_one::<String>("space").unwrap().clone();
crate::validate::validate_resource_name(&space)?;
Ok(SendConfig {
space,
text: matches.get_one::<String>("text").unwrap().clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"create".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut messages_res = RestResource::default();
messages_res.methods = methods;
let mut spaces_res = RestResource::default();
spaces_res
.resources
.insert("messages".to_string(), messages_res);
let mut resources = HashMap::new();
resources.insert("spaces".to_string(), spaces_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_send(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("space").long("space"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_send_request() {
let doc = make_mock_doc();
let config = SendConfig {
space: "spaces/123".to_string(),
text: "hello chat".to_string(),
};
let (params, body, scopes) = build_send_request(&config, &doc).unwrap();
assert!(params.contains("spaces/123"));
assert!(body.contains("hello chat"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_parse_send_args() {
let matches = make_matches_send(&["test", "--space", "valid-space", "--text", "t"]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.space, "valid-space");
assert_eq!(config.text, "t");
}
#[test]
fn test_parse_send_args_rejects_traversal_in_space() {
let matches = make_matches_send(&["test", "--space", "../etc/passwd", "--text", "t"]);
let result = parse_send_args(&matches);
assert!(
result.is_err(),
"space with path traversal should be rejected"
);
}
#[test]
fn test_parse_send_args_rejects_query_injection_in_space() {
let matches =
make_matches_send(&["test", "--space", "spaces/AAA?key=injected", "--text", "t"]);
let result = parse_send_args(&matches);
assert!(
result.is_err(),
"space with query characters should be rejected"
);
}
#[test]
fn test_inject_commands() {
let helper = ChatHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+send"));
}
}
@@ -0,0 +1,206 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct DocsHelper;
impl Helper for DocsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+write")
.about("[Helper] Append text to a document")
.arg(
Arg::new("document")
.long("document")
.help("Document ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text to append (plain text)")
.required(true)
.value_name("TEXT"),
)
.after_help(
"\
EXAMPLES:
gws docs +write --document DOC_ID --text 'Hello, world!'
TIPS:
Text is inserted at the end of the document body.
For rich formatting, use the raw batchUpdate API instead.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+write") {
let (params_str, body_str, scopes) = build_write_request(matches, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))),
};
// Method: documents.batchUpdate
let documents_res = doc.resources.get("documents").ok_or_else(|| {
GwsError::Discovery("Resource 'documents' not found".to_string())
})?;
let batch_update_method =
documents_res.methods.get("batchUpdate").ok_or_else(|| {
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
batch_update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_write_request(
matches: &ArgMatches,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let document_id = matches.get_one::<String>("document").unwrap();
let text = matches.get_one::<String>("text").unwrap();
let documents_res = doc
.resources
.get("documents")
.ok_or_else(|| GwsError::Discovery("Resource 'documents' not found".to_string()))?;
let batch_update_method = documents_res.methods.get("batchUpdate").ok_or_else(|| {
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
})?;
let params = json!({
"documentId": document_id
});
let body = json!({
"requests": [
{
"insertText": {
"text": text,
"endOfSegmentLocation": {
"segmentId": "" // Empty means body
}
}
}
]
});
let scopes: Vec<String> = batch_update_method
.scopes
.iter()
.map(|s| s.to_string())
.collect();
Ok((params.to_string(), body.to_string(), scopes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"batchUpdate".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut documents_res = RestResource::default();
documents_res.methods = methods;
let mut resources = HashMap::new();
resources.insert("documents".to_string(), documents_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_write(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("document").long("document"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_write_request() {
let doc = make_mock_doc();
let matches = make_matches_write(&["test", "--document", "123", "--text", "hello world"]);
let (params, body, scopes) = build_write_request(&matches, &doc).unwrap();
assert!(params.contains("123"));
assert!(body.contains("hello world"));
assert!(body.contains("endOfSegmentLocation"));
assert_eq!(scopes[0], "https://scope");
}
}
@@ -0,0 +1,196 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub struct DriveHelper;
impl Helper for DriveHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+upload")
.about("[Helper] Upload a file with automatic metadata")
.arg(
Arg::new("file")
.help("Path to file to upload")
.required(true)
.index(1),
)
.arg(
Arg::new("parent")
.long("parent")
.help("Parent folder ID")
.value_name("ID"),
)
.arg(
Arg::new("name")
.long("name")
.help("Target filename (defaults to source filename)")
.value_name("NAME"),
)
.after_help(
"\
EXAMPLES:
gws drive +upload ./report.pdf
gws drive +upload ./report.pdf --parent FOLDER_ID
gws drive +upload ./data.csv --name 'Sales Data.csv'
TIPS:
MIME type is detected automatically.
Filename is inferred from the local path unless --name is given.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+upload") {
let file_path = matches.get_one::<String>("file").unwrap();
let parent_id = matches.get_one::<String>("parent");
let name_arg = matches.get_one::<String>("name");
// Determine filename
let filename = determine_filename(file_path, name_arg.map(|s| s.as_str()))?;
// Find method: files.create
let files_res = doc
.resources
.get("files")
.ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?;
let create_method = files_res.methods.get("create").ok_or_else(|| {
GwsError::Discovery("Method 'files.create' not found".to_string())
})?;
// Build metadata
let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()));
let body_str = metadata.to_string();
let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Drive auth failed: {e}"))),
};
executor::execute_method(
doc,
create_method,
None,
Some(&body_str),
token.as_deref(),
auth_method,
None,
Some(executor::UploadSource::File {
path: file_path,
content_type: None,
}),
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String, GwsError> {
if let Some(n) = name_arg {
Ok(n.to_string())
} else {
Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| GwsError::Validation("Invalid file path".to_string()))
}
}
fn build_metadata(filename: &str, parent_id: Option<&str>) -> Value {
let mut metadata = json!({
"name": filename
});
if let Some(parent) = parent_id {
metadata["parents"] = json!([parent]);
}
metadata
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determine_filename_explicit() {
assert_eq!(
determine_filename("path/to/file.txt", Some("custom.txt")).unwrap(),
"custom.txt"
);
}
#[test]
fn test_determine_filename_from_path() {
assert_eq!(
determine_filename("path/to/file.txt", None).unwrap(),
"file.txt"
);
}
#[test]
fn test_determine_filename_invalid_path() {
assert!(determine_filename("", None).is_err());
assert!(determine_filename("/", None).is_err()); // Root has no filename component usually
}
#[test]
fn test_build_metadata_no_parent() {
let meta = build_metadata("file.txt", None);
assert_eq!(meta["name"], "file.txt");
assert!(meta.get("parents").is_none());
}
#[test]
fn test_build_metadata_with_parent() {
let meta = build_metadata("file.txt", Some("folder123"));
assert_eq!(meta["name"], "file.txt");
assert_eq!(meta["parents"][0], "folder123");
}
}
@@ -0,0 +1,210 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
pub mod renew;
pub mod subscribe;
use renew::handle_renew;
use subscribe::handle_subscribe;
pub(super) use crate::auth;
pub(super) use crate::error::GwsError;
pub(super) use anyhow::Context;
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};
pub(super) use derive_builder::Builder;
pub(super) use serde_json::{json, Value};
pub(super) use std::future::Future;
pub(super) use std::pin::Pin;
pub struct EventsHelper;
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
pub(super) const WORKSPACE_EVENTS_SCOPE: &str =
"https://www.googleapis.com/auth/chat.spaces.readonly";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectId(pub String);
impl std::fmt::Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionName(pub String);
impl std::fmt::Display for SubscriptionName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Helper for EventsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+subscribe")
.about("[Helper] Subscribe to Workspace events and stream them as NDJSON")
.arg(
Arg::new("target")
.long("target")
.help(
"Workspace resource URI (e.g., //chat.googleapis.com/spaces/SPACE_ID)",
)
.value_name("URI"),
)
.arg(
Arg::new("event-types")
.long("event-types")
.help("Comma-separated CloudEvents types to subscribe to")
.value_name("TYPES"),
)
.arg(
Arg::new("project")
.long("project")
.help("GCP project ID for Pub/Sub resources")
.value_name("PROJECT"),
)
.arg(
Arg::new("subscription")
.long("subscription")
.help("Existing Pub/Sub subscription name (skip setup)")
.value_name("NAME"),
)
.arg(
Arg::new("max-messages")
.long("max-messages")
.help("Max messages per pull batch (default: 10)")
.value_name("N")
.default_value("10"),
)
.arg(
Arg::new("poll-interval")
.long("poll-interval")
.help("Seconds between pulls (default: 5)")
.value_name("SECS")
.default_value("5"),
)
.arg(
Arg::new("once")
.long("once")
.help("Pull once and exit")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("cleanup")
.long("cleanup")
.help("Delete created Pub/Sub resources on exit")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-ack")
.long("no-ack")
.help("Don't auto-acknowledge messages")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("output-dir")
.long("output-dir")
.help("Write each event to a separate JSON file in this directory")
.value_name("DIR"),
)
.after_help("\
EXAMPLES:
gws events +subscribe --target '//chat.googleapis.com/spaces/SPACE' --event-types 'google.workspace.chat.message.v1.created' --project my-project
gws events +subscribe --subscription projects/p/subscriptions/my-sub --once
gws events +subscribe ... --cleanup --output-dir ./events
TIPS:
Without --cleanup, Pub/Sub resources persist for reconnection.
Press Ctrl-C to stop gracefully."),
);
cmd = cmd.subcommand(
Command::new("+renew")
.about("[Helper] Renew/reactivate Workspace Events subscriptions")
.arg(
Arg::new("name")
.long("name")
.help("Subscription name to reactivate (e.g., subscriptions/SUB_ID)")
.value_name("NAME"),
)
.arg(
Arg::new("all")
.long("all")
.help("Renew all subscriptions expiring within --within window")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("within")
.long("within")
.help("Time window for --all (e.g., 1h, 30m, 2d)")
.value_name("DURATION")
.default_value("1h"),
)
.after_help(
"\
EXAMPLES:
gws events +renew --name subscriptions/SUB_ID
gws events +renew --all --within 2d
TIPS:
Subscriptions expire if not renewed periodically.
Use --all with a cron job to keep subscriptions alive.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(sub_matches) = matches.subcommand_matches("+subscribe") {
handle_subscribe(doc, sub_matches).await?;
return Ok(true);
}
if let Some(renew_matches) = matches.subcommand_matches("+renew") {
handle_renew(doc, renew_matches).await?;
return Ok(true);
}
Ok(false)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_commands() {
let helper = EventsHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+subscribe"));
assert!(subcommands.contains(&"+renew"));
}
}
@@ -0,0 +1,283 @@
use super::*;
#[derive(Debug, PartialEq)]
pub struct RenewConfig {
pub name: Option<String>,
pub all: bool,
pub within: String,
}
fn parse_renew_args(matches: &ArgMatches) -> Result<RenewConfig, GwsError> {
let name = matches.get_one::<String>("name").cloned();
let all = matches.get_flag("all");
let within = matches
.get_one::<String>("within")
.cloned()
.unwrap_or_else(|| "1h".to_string());
if name.is_none() && !all {
return Err(GwsError::Validation(
"Either --name or --all is required for +renew".to_string(),
));
}
Ok(RenewConfig { name, all, within })
}
/// Handles the `+renew` command.
pub(super) async fn handle_renew(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_renew_args(matches)?;
let dry_run = matches.get_flag("dry-run");
if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
// Handle dry-run case and exit early
let result = if let Some(name) = config.name {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
json!({
"dry_run": true,
"action": "Would reactivate subscription",
"name": name,
"note": "Run without --dry-run to actually reactivate the subscription"
})
} else {
json!({
"dry_run": true,
"action": "Would list and renew subscriptions expiring within",
"within": config.within,
"note": "Run without --dry-run to actually renew subscriptions"
})
};
println!(
"{}",
serde_json::to_string_pretty(&result).context("Failed to serialize dry-run output")?
);
return Ok(());
}
// Real run logic
let client = crate::client::build_client()?;
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
if let Some(name) = config.name {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
let resp = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to reactivate subscription")?;
let body: Value = resp.json().await.context("Failed to parse response")?;
println!(
"{}",
serde_json::to_string_pretty(&body).context("Failed to serialize response body")?
);
} else {
let within_secs = parse_duration(&config.within)?;
let resp = client
.get("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(&ws_token)
.send()
.await
.context("Failed to list subscriptions")?;
let body: Value = resp.json().await.context("Failed to parse response")?;
let mut renewed = 0;
if let Some(subs) = body.get("subscriptions").and_then(|s| s.as_array()) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);
for name in to_renew {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Renewing {name}...");
let _ = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await;
renewed += 1;
}
}
let result = json!({
"status": "success",
"renewed": renewed,
});
println!(
"{}",
serde_json::to_string_pretty(&result).context("Failed to serialize result")?
);
}
Ok(())
}
fn filter_subscriptions_to_renew(subs: &[Value], now_secs: u64, within_secs: u64) -> Vec<String> {
let mut result = Vec::new();
for sub in subs {
if let Some(expire_time) = sub.get("expireTime").and_then(|e| e.as_str()) {
if let Some(expire_secs) = parse_rfc3339_rough(expire_time) {
let remaining = expire_secs.saturating_sub(now_secs);
if remaining < within_secs {
if let Some(name) = sub.get("name").and_then(|n| n.as_str()) {
result.push(name.to_string());
}
}
}
}
}
result
}
/// Parses a duration string like "1h", "30m", "2d" into seconds.
fn parse_duration(s: &str) -> Result<u64, GwsError> {
let s = s.trim();
if s.is_empty() {
return Err(GwsError::Validation("Empty duration".to_string()));
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.map_err(|_| GwsError::Validation(format!("Invalid duration: {s}")))?;
match unit {
"s" => Ok(num),
"m" => Ok(num * 60),
"h" => Ok(num * 3600),
"d" => Ok(num * 86400),
_ => Err(GwsError::Validation(format!(
"Unknown duration unit '{unit}'. Use s, m, h, or d."
))),
}
}
/// Parse an RFC 3339 timestamp to Unix seconds.
fn parse_rfc3339_rough(s: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.timestamp() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_matches_renew(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("name").long("name"))
.arg(Arg::new("all").long("all").action(ArgAction::SetTrue))
.arg(Arg::new("within").long("within").default_value("1h"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap(), 3600);
assert_eq!(parse_duration("2h").unwrap(), 7200);
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("30m").unwrap(), 1800);
}
#[test]
fn test_parse_duration_days() {
assert_eq!(parse_duration("1d").unwrap(), 86400);
assert_eq!(parse_duration("7d").unwrap(), 604800);
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
}
#[test]
fn test_parse_rfc3339_rough() {
// 2026-02-13T10:00:00Z
let ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
assert!(ts > 0);
// Check simple calculation logic (not verifying exact epoch seconds against a full library)
// just consistency
let ts2 = parse_rfc3339_rough("2026-02-13T10:00:01Z").unwrap();
assert_eq!(ts2, ts + 1);
}
#[test]
fn test_parse_renew_args_name() {
let matches = make_matches_renew(&["test", "--name", "subs/123"]);
let config = parse_renew_args(&matches).unwrap();
assert_eq!(config.name, Some("subs/123".to_string()));
assert!(!config.all);
}
#[test]
fn test_parse_renew_args_all() {
let matches = make_matches_renew(&["test", "--all", "--within", "2h"]);
let config = parse_renew_args(&matches).unwrap();
assert!(config.name.is_none());
assert!(config.all);
assert_eq!(config.within, "2h");
}
#[test]
fn test_parse_renew_args_missing() {
let matches = make_matches_renew(&["test"]);
assert!(parse_renew_args(&matches).is_err());
}
#[test]
fn test_filter_subscriptions_to_renew() {
// Let's use `parse_rfc3339_rough` to get a baseline
let base_ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
// sub1 expires in 30m (1800s from base)
let sub1 = json!({
"name": "subs/1",
"expireTime": "2026-02-13T10:30:00Z"
});
// sub2 expires in 2h (7200s from base)
let sub2 = json!({
"name": "subs/2",
"expireTime": "2026-02-13T12:00:00Z"
});
let subs = vec![sub1, sub2];
// within 1h (3600s) -> should catch sub1 (1800s < 3600s) but not sub2 (7200s > 3600s)
let to_renew = filter_subscriptions_to_renew(&subs, base_ts, 3600);
assert_eq!(to_renew.len(), 1);
assert_eq!(to_renew[0], "subs/1");
// within 3h (10800s) -> should catch both
let to_renew_all = filter_subscriptions_to_renew(&subs, base_ts, 10800);
assert_eq!(to_renew_all.len(), 2);
}
}
@@ -0,0 +1,945 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::sanitize_for_terminal;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Builder)]
#[builder(setter(into))]
pub struct SubscribeConfig {
#[builder(default)]
target: Option<String>,
#[builder(default)]
event_types: Vec<String>,
#[builder(default)]
project: Option<ProjectId>,
#[builder(default)]
subscription: Option<SubscriptionName>,
#[builder(default = "10")]
max_messages: u32,
#[builder(default = "2")]
poll_interval: u64,
#[builder(default)]
once: bool,
#[builder(default)]
cleanup: bool,
#[builder(default)]
no_ack: bool,
#[builder(default)]
output_dir: Option<PathBuf>,
}
fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsError> {
let mut builder = SubscribeConfigBuilder::default();
if let Some(target) = matches.get_one::<String>("target") {
builder.target(Some(target.clone()));
}
if let Some(event_types) = matches.get_one::<String>("event-types") {
builder.event_types(
event_types
.split(',')
.map(|t| t.trim().to_string())
.collect::<Vec<_>>(),
);
}
if let Some(project) = matches
.get_one::<String>("project")
.cloned()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
{
builder.project(Some(ProjectId(project)));
}
if let Some(subscription) = matches.get_one::<String>("subscription") {
crate::validate::validate_resource_name(subscription)?;
builder.subscription(Some(SubscriptionName(subscription.clone())));
}
if let Some(max_messages) = matches
.get_one::<String>("max-messages")
.and_then(|s| s.parse::<u32>().ok())
{
builder.max_messages(max_messages);
}
if let Some(poll_interval) = matches
.get_one::<String>("poll-interval")
.and_then(|s| s.parse::<u64>().ok())
{
builder.poll_interval(poll_interval);
}
builder.once(matches.get_flag("once"));
builder.cleanup(matches.get_flag("cleanup"));
builder.no_ack(matches.get_flag("no-ack"));
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
builder.output_dir(Some(crate::validate::validate_safe_output_dir(output_dir)?));
}
let config = builder
.build()
.map_err(|e| GwsError::Validation(e.to_string()))?;
validate_subscribe_config(&config)?;
Ok(config)
}
fn validate_subscribe_config(config: &SubscribeConfig) -> Result<(), GwsError> {
if config.subscription.is_none() {
if config.target.is_none() {
return Err(GwsError::Validation(
"--target is required when not using --subscription".to_string(),
));
}
if config.event_types.is_empty() {
return Err(GwsError::Validation(
"--event-types is required when not using --subscription".to_string(),
));
}
if config.project.is_none() {
return Err(GwsError::Validation(
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
));
}
}
Ok(())
}
/// Handles the `+subscribe` command.
pub(super) async fn handle_subscribe(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_subscribe_args(matches)?;
let dry_run = matches.get_flag("dry-run");
if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
}
if let Some(ref dir) = config.output_dir {
if !dry_run {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
}
}
let client = crate::client::build_client()?;
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
let (pubsub_subscription, topic_name, ws_subscription_name, created_resources) =
if let Some(ref sub_name) = config.subscription {
// Use existing subscription — no setup needed
// (don't fetch Pub/Sub token since we won't need it for existing subscriptions)
if dry_run {
eprintln!("Would listen to existing subscription: {}", sub_name.0);
let result = json!({
"dry_run": true,
"action": "Would listen to existing subscription",
"subscription": sub_name.0,
"note": "Run without --dry-run to actually start listening"
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.context("Failed to serialize dry-run output")?
);
return Ok(());
}
(sub_name.0.clone(), None, None, false)
} else {
// Get Pub/Sub token only when creating new subscription
let pubsub_token = if dry_run {
None
} else {
Some(
auth::get_token(&[PUBSUB_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?,
)
};
// Full setup: create Pub/Sub topic + subscription + Workspace Events subscription
// Validate target before use in both dry-run and actual execution paths
let target = crate::validate::validate_resource_name(&config.target.clone().unwrap())?
.to_string();
let project =
crate::validate::validate_resource_name(&config.project.clone().unwrap().0)?
.to_string();
let event_types_str: Vec<&str> =
config.event_types.iter().map(|s| s.as_str()).collect();
// Generate descriptive names from event types
// e.g. "google.workspace.drive.file.v1.updated" -> "drive-file-updated"
let slug = derive_slug_from_event_types(&event_types_str);
let suffix = format!("{:08x}", rand::random::<u32>());
let topic = format!("projects/{project}/topics/gws-{slug}-{suffix}");
let sub = format!("projects/{project}/subscriptions/gws-{slug}-{suffix}");
// Dry-run: print what would be created and exit
if dry_run {
eprintln!("Would create Pub/Sub topic: {topic}");
eprintln!("Would create Pub/Sub subscription: {sub}");
eprintln!("Would create Workspace Events subscription for target: {target}");
eprintln!(
"Would listen for event types: {}",
config.event_types.join(", ")
);
let result = json!({
"dry_run": true,
"action": "Would create Workspace Events subscription",
"pubsub_topic": topic,
"pubsub_subscription": sub,
"target": target,
"event_types": config.event_types,
"note": "Run without --dry-run to actually create subscription"
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.context("Failed to serialize dry-run output")?
);
return Ok(());
}
// 1. Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {topic}");
let token = pubsub_token.as_ref().ok_or_else(|| {
GwsError::Auth(
"Token unavailable in non-dry-run mode. This indicates a bug.".to_string(),
)
})?;
let resp = client
.put(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to create topic")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub topic: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 2. Create Pub/Sub subscription
eprintln!("Creating Pub/Sub subscription: {sub}");
let sub_body = json!({
"topic": topic,
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(&sub_body)
.send()
.await
.context("Failed to create subscription")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub subscription: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 3. Create Workspace Events subscription
eprintln!("Creating Workspace Events subscription...");
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| {
GwsError::Auth(format!("Failed to get Workspace Events token: {e}"))
})?;
let ws_body = json!({
"targetResource": target,
"eventTypes": config.event_types,
"notificationEndpoint": {
"pubsubTopic": topic,
},
"payloadOptions": {
"includeResource": true,
},
});
let resp = client
.post("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.json(&ws_body)
.send()
.await
.context("Failed to create Workspace Events subscription")?;
let resp_body: Value = resp
.json()
.await
.context("Failed to parse subscription response")?;
let ws_sub_name = resp_body
// Direct subscription response
.get("name")
.and_then(|v| v.as_str())
.filter(|s| s.starts_with("subscriptions/"))
.or_else(|| {
// LRO response — check response.name
resp_body
.get("response")
.and_then(|r| r.get("name"))
.and_then(|v| v.as_str())
})
.or_else(|| {
// LRO response — check metadata.subscription
resp_body
.get("metadata")
.and_then(|m| m.get("subscription"))
.and_then(|v| v.as_str())
})
.or_else(|| {
// Fall back to the operation name itself
resp_body.get("name").and_then(|v| v.as_str())
})
.unwrap_or("pending")
.to_string();
eprintln!("Workspace Events subscription: {ws_sub_name}");
eprintln!("Listening for events...\n");
(sub, Some(topic), Some(ws_sub_name), true)
};
// Pull loop
let result = pull_loop(
&client,
&pubsub_token_provider,
&pubsub_subscription,
config.clone(),
PUBSUB_API_BASE,
)
.await;
// On exit, print reconnection info or cleanup
if created_resources {
if config.cleanup {
eprintln!("\nCleaning up Pub/Sub resources...");
// Delete Pub/Sub subscription
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{pubsub_subscription}"))
.bearer_auth(&pubsub_token)
.send()
.await;
// Delete Pub/Sub topic
if let Some(ref topic) = topic_name {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(&pubsub_token)
.send()
.await;
}
eprintln!("Cleanup complete.");
} else {
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
}
} else {
eprintln!("\n--- Reconnection Info ---");
eprintln!(
"To reconnect later:\n gws events +subscribe --subscription {}",
pubsub_subscription
);
if let Some(ref ws_name) = ws_subscription_name {
eprintln!("Workspace Events subscription: {ws_name}");
}
if let Some(ref topic) = topic_name {
eprintln!("Pub/Sub topic: {topic}");
}
eprintln!("Pub/Sub subscription: {pubsub_subscription}");
eprintln!("To clean up manually:");
if let Some(ref topic) = topic_name {
eprintln!(
" gcloud pubsub subscriptions delete {}",
pubsub_subscription
);
eprintln!(" gcloud pubsub topics delete {topic}");
}
}
}
result
}
/// Pulls messages from a Pub/Sub subscription in a loop.
async fn pull_loop(
client: &reqwest::Client,
token_provider: &dyn auth::AccessTokenProvider,
subscription: &str,
config: SubscribeConfig,
pubsub_api_base: &str,
) -> Result<(), GwsError> {
let mut file_counter: u64 = 0;
loop {
let token = token_provider
.access_token()
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?;
let pull_body = json!({
"maxMessages": config.max_messages,
});
let pull_future = client
.post(format!("{pubsub_api_base}/{subscription}:pull"))
.bearer_auth(&token)
.header("Content-Type", "application/json")
.json(&pull_body)
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
.send();
let resp = tokio::select! {
result = pull_future => {
match result {
Ok(r) => r,
Err(e) if e.is_timeout() => continue,
Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()),
}
}
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Pub/Sub pull failed: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
let (ack_ids, events) = process_events_pull_response(&pull_response);
for event in events {
let json_str =
serde_json::to_string_pretty(&event).unwrap_or_else(|_| "{}".to_string());
if let Some(ref dir) = config.output_dir {
file_counter += 1;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = dir.join(format!("{ts}_{file_counter}.json"));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!(
"Warning: failed to write {}: {}",
path.display(),
sanitize_for_terminal(&e.to_string())
);
} else {
eprintln!("Wrote {}", path.display());
}
} else {
println!(
"{}",
serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_string())
);
}
}
// Acknowledge messages
if !config.no_ack && !ack_ids.is_empty() {
let ack_body = json!({
"ackIds": ack_ids,
});
let _ = client
.post(format!("{pubsub_api_base}/{subscription}:acknowledge"))
.bearer_auth(&token)
.header("Content-Type", "application/json")
.json(&ack_body)
.send()
.await;
}
if config.once {
break;
}
// Check for SIGINT/SIGTERM between polls
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
}
Ok(())
}
fn process_events_pull_response(response: &Value) -> (Vec<String>, Vec<Value>) {
let mut ack_ids = Vec::new();
let mut events = Vec::new();
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
for msg in messages {
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
ack_ids.push(ack_id.to_string());
}
if let Some(pubsub_msg) = msg.get("message") {
events.push(decode_cloud_event(pubsub_msg));
}
}
}
(ack_ids, events)
}
/// Decodes a Pub/Sub message containing a CloudEvent.
fn decode_cloud_event(pubsub_msg: &Value) -> Value {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let attributes = pubsub_msg.get("attributes").cloned().unwrap_or(json!({}));
let event_type = attributes
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let source = attributes
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let time = attributes
.get("time")
.and_then(|v| v.as_str())
.unwrap_or("");
// Decode base64 data
let data = pubsub_msg
.get("data")
.and_then(|d| d.as_str())
.and_then(|d| STANDARD.decode(d).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or(json!(null));
json!({
"type": event_type,
"source": source,
"time": time,
"attributes": attributes,
"data": data,
})
}
/// Derives a readable slug from event types for Pub/Sub resource naming.
/// e.g. ["google.workspace.drive.file.v1.updated"] -> "drive-file-updated"
/// Multiple types are joined: ["...drive.file.v1.updated", "...drive.file.v1.created"] -> "drive-file-updated-created"
fn derive_slug_from_event_types(event_types: &[&str]) -> String {
let parts: Vec<String> = event_types
.iter()
.map(|et| {
// Strip "google.workspace." prefix and version segment
let stripped = et.strip_prefix("google.workspace.").unwrap_or(et);
// Split by '.', remove version-like segments (e.g. "v1")
let segments: Vec<&str> = stripped
.split('.')
.filter(|s| {
!s.starts_with('v')
|| s.len() > 3
|| !s[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
segments.join("-")
})
.collect();
let slug = if parts.len() == 1 {
parts[0].clone()
} else {
// Find common prefix across event types, then append distinct suffixes
let first_segments: Vec<&str> = parts[0].split('-').collect();
let mut common_len = 0;
'outer: for i in 0..first_segments.len() {
for p in &parts[1..] {
let segs: Vec<&str> = p.split('-').collect();
if i >= segs.len() || segs[i] != first_segments[i] {
break 'outer;
}
}
common_len = i + 1;
}
let prefix = first_segments[..common_len].join("-");
let suffixes: Vec<String> = parts
.iter()
.map(|p| {
let segs: Vec<&str> = p.split('-').collect();
segs[common_len..].join("-")
})
.filter(|s| !s.is_empty())
.collect();
if suffixes.is_empty() {
prefix
} else {
format!("{}-{}", prefix, suffixes.join("-"))
}
};
// Truncate to keep Pub/Sub resource names within limits
let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
slug.trim_end_matches('-').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::FakeTokenProvider;
use base64::Engine as _;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn spawn_subscribe_server() -> (
String,
Arc<Mutex<Vec<(String, String)>>>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded_requests = Arc::clone(&requests);
let handle = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0_u8; 8192];
let bytes_read = stream.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..bytes_read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let auth_header = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.unwrap_or("")
.trim()
.to_string();
recorded_requests
.lock()
.await
.push((path.clone(), auth_header));
let body = match path.as_str() {
"/v1/projects/test/subscriptions/demo:pull" => json!({
"receivedMessages": [{
"ackId": "ack-1",
"message": {
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat/spaces/A"
},
"data": base64::engine::general_purpose::STANDARD
.encode(json!({ "id": "evt-1" }).to_string())
}
}]
})
.to_string(),
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
other => panic!("unexpected request path: {other}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
(format!("http://{addr}/v1"), requests, handle)
}
fn make_matches_subscribe(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("target").long("target"))
.arg(Arg::new("event-types").long("event-types"))
.arg(Arg::new("project").long("project"))
.arg(Arg::new("subscription").long("subscription"))
.arg(Arg::new("max-messages").long("max-messages"))
.arg(Arg::new("poll-interval").long("poll-interval"))
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
.arg(
Arg::new("cleanup")
.long("cleanup")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("no-ack").long("no-ack").action(ArgAction::SetTrue))
.arg(Arg::new("output-dir").long("output-dir"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_subscribe_args_invalid_output_dir() {
let matches = make_matches_subscribe(&["test", "--output-dir", "../../etc"]);
let result = parse_subscribe_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_subscribe_args() {
let matches = make_matches_subscribe(&[
"test",
"--target",
"//chat/spaces/A",
"--event-types",
"type1,type2",
"--project",
"my-project",
"--max-messages",
"20",
"--once",
]);
let config = parse_subscribe_args(&matches).unwrap();
assert_eq!(config.target, Some("//chat/spaces/A".to_string()));
assert_eq!(config.event_types, vec!["type1", "type2"]);
assert_eq!(config.project, Some(ProjectId("my-project".to_string())));
assert_eq!(config.max_messages, 20);
assert!(config.once);
assert!(!config.cleanup);
}
#[test]
fn test_parse_subscribe_args_subscription() {
let matches = make_matches_subscribe(&["test", "--subscription", "subs/my-sub"]);
let config = parse_subscribe_args(&matches).unwrap();
assert_eq!(
config.subscription,
Some(SubscriptionName("subs/my-sub".to_string()))
);
// Others defaults
assert_eq!(config.max_messages, 10);
}
#[test]
fn test_slug_single_event_type() {
let types = vec!["google.workspace.drive.file.v1.updated"];
assert_eq!(derive_slug_from_event_types(&types), "drive-file-updated");
}
#[test]
fn test_slug_single_event_type_chat() {
let types = vec!["google.workspace.chat.message.v1.created"];
assert_eq!(derive_slug_from_event_types(&types), "chat-message-created");
}
#[test]
fn test_slug_multiple_event_types_common_prefix() {
let types = vec![
"google.workspace.drive.file.v1.updated",
"google.workspace.drive.file.v1.created",
];
let slug = derive_slug_from_event_types(&types);
assert_eq!(slug, "drive-file-updated-created");
}
#[test]
fn test_slug_non_workspace_prefix() {
let types = vec!["custom.event.type"];
let slug = derive_slug_from_event_types(&types);
assert_eq!(slug, "custom-event-type");
}
#[test]
fn test_slug_truncation() {
// Very long event type should be truncated to 40 chars
let types = vec!["google.workspace.very.long.service.name.with.many.segments.v1.updated"];
let slug = derive_slug_from_event_types(&types);
assert!(slug.len() <= 40);
}
#[test]
fn test_decode_cloud_event() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let data = json!({"foo": "bar"}).to_string();
let encoded = STANDARD.encode(data);
let msg = json!({
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat.googleapis.com/spaces/AAA",
"time": "2026-02-13T10:00:00Z"
},
"data": encoded
});
let event = decode_cloud_event(&msg);
assert_eq!(event["type"], "google.workspace.chat.message.v1.created");
assert_eq!(event["source"], "//chat.googleapis.com/spaces/AAA");
assert_eq!(event["data"]["foo"], "bar");
}
#[test]
fn test_process_events_pull_response() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
// Mock a Pub/Sub response with two messages
let data1 = json!({"id": "1", "content": "hello"}).to_string();
let encoded1 = STANDARD.encode(data1);
let data2 = json!({"id": "2", "content": "world"}).to_string();
let encoded2 = STANDARD.encode(data2);
let response = json!({
"receivedMessages": [
{
"ackId": "ack1",
"message": {
"attributes": {
"type": "google.workspace.chat.message.v1.created",
"source": "//chat/spaces/A"
},
"data": encoded1,
"messageId": "msg1"
}
},
{
"ackId": "ack2",
"message": {
"attributes": {
"type": "google.workspace.drive.file.v1.updated",
"source": "//drive/files/B"
},
"data": encoded2,
"messageId": "msg2"
}
}
]
});
let (ack_ids, events) = process_events_pull_response(&response);
assert_eq!(ack_ids.len(), 2);
assert_eq!(ack_ids[0], "ack1");
assert_eq!(ack_ids[1], "ack2");
assert_eq!(events.len(), 2);
assert_eq!(
events[0]["type"],
"google.workspace.chat.message.v1.created"
);
assert_eq!(events[0]["data"]["id"], "1");
assert_eq!(events[1]["type"], "google.workspace.drive.file.v1.updated");
assert_eq!(events[1]["data"]["id"], "2");
}
#[test]
fn test_process_events_pull_response_empty() {
let response = json!({});
let (ack_ids, events) = process_events_pull_response(&response);
assert!(ack_ids.is_empty());
assert!(events.is_empty());
}
#[test]
fn test_handle_subscribe_validation_missing_target() {
let config = SubscribeConfigBuilder::default()
.event_types(vec!["type1".to_string()])
.project(Some(ProjectId("p1".to_string())))
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--target is required"));
}
#[test]
fn test_handle_subscribe_validation_missing_events() {
let config = SubscribeConfigBuilder::default()
.target(Some("target1".to_string()))
.project(Some(ProjectId("p1".to_string())))
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--event-types is required"));
}
#[test]
fn test_handle_subscribe_validation_missing_project() {
let config = SubscribeConfigBuilder::default()
.target(Some("target1".to_string()))
.event_types(vec!["type1".to_string()])
.build()
.unwrap();
let result = validate_subscribe_config(&config);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("--project is required"));
}
#[tokio::test]
async fn test_pull_loop_refreshes_pubsub_token_between_requests() {
let client = reqwest::Client::new();
let token_provider = FakeTokenProvider::new(["pubsub-token"]);
let (pubsub_base, requests, server) = spawn_subscribe_server().await;
let config = SubscribeConfigBuilder::default()
.subscription(Some(SubscriptionName(
"projects/test/subscriptions/demo".to_string(),
)))
.max_messages(1_u32)
.poll_interval(1_u64)
.once(true)
.build()
.unwrap();
pull_loop(
&client,
&token_provider,
"projects/test/subscriptions/demo",
config,
&pubsub_base,
)
.await
.unwrap();
server.await.unwrap();
let requests = requests.lock().await;
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
assert_eq!(
requests[1].0,
"/v1/projects/test/subscriptions/demo:acknowledge"
);
assert_eq!(requests[1].1, "authorization: Bearer pubsub-token");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use std::io::{self, Write};
/// Handle the `+read` subcommand.
pub(super) async fn handle_read(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let message_id = matches.get_one::<String>("id").unwrap();
let dry_run = matches.get_flag("dry-run");
let original = if dry_run {
OriginalMessage::dry_run_placeholder(message_id)
} else {
let t = auth::get_token(&[GMAIL_READONLY_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
fetch_message_metadata(&client, &t, message_id).await?
};
let format = matches.get_one::<String>("format").unwrap();
let show_headers = matches.get_flag("headers");
let use_html = matches.get_flag("html");
let mut stdout = io::stdout().lock();
if format == "json" {
let json_output = serde_json::to_string_pretty(&original)
.context("Failed to serialize message to JSON")?;
writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?;
return Ok(());
}
if show_headers {
// Format structured fields into display strings for header output.
let from_str = original.from.to_string();
let to_str = format_mailbox_list(&original.to);
let cc_str = original
.cc
.as_ref()
.map(|cc| format_mailbox_list(cc))
.unwrap_or_default();
let headers_to_show: [(&str, &str); 5] = [
("From", &from_str),
("To", &to_str),
("Cc", &cc_str),
("Subject", &original.subject),
("Date", original.date.as_deref().unwrap_or_default()),
];
for (name, value) in headers_to_show {
if value.is_empty() {
continue;
}
// Replace newlines to prevent header spoofing in the output, then sanitize.
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
writeln!(stdout, "{}: {}", name, sanitized_value)
.with_context(|| format!("Failed to write '{name}' header"))?;
}
writeln!(stdout, "---").context("Failed to write header separator")?;
}
let body = if use_html {
original
.body_html
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(&original.body_text)
} else {
&original.body_text
};
writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;
Ok(())
}
/// Format a slice of Mailbox as a displayable comma-separated string.
fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
mailboxes
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ")
}
use crate::output::sanitize_for_terminal;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_for_terminal() {
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
let sanitized = sanitize_for_terminal(malicious);
// ANSI escape sequences (control chars) should be removed
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
// CR is also stripped (can be abused for terminal overwrite attacks)
assert!(!sanitized.contains('\r'));
// Newline and tab should be preserved
assert!(sanitized.contains("Hello"));
assert!(sanitized.contains('\n'));
assert!(sanitized.contains('\t'));
}
#[test]
fn test_format_mailbox_list_empty() {
assert_eq!(format_mailbox_list(&[]), "");
}
#[test]
fn test_format_mailbox_list_single() {
let mailboxes = Mailbox::parse_list("alice@example.com");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
}
#[test]
fn test_format_mailbox_list_multiple() {
let mailboxes = Mailbox::parse_list("alice@example.com, Bob <bob@example.com>");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
assert!(result.contains("bob@example.com"));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
/// Handle the `+send` subcommand.
pub(super) async fn handle_send(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let mut config = parse_send_args(matches)?;
let dry_run = matches.get_flag("dry-run");
let token = if dry_run {
None
} else {
// Resolve the target method (send or draft) and use its discovery
// doc scopes, so the token matches the operation. resolve_sender
// gracefully degrades if the token doesn't cover the sendAs.list
// endpoint.
let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?;
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
let t = auth::get_token(&scopes)
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
Some(t)
};
let raw = create_send_raw_message(&config)?;
super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await
}
pub(super) struct SendConfig {
pub to: Vec<Mailbox>,
pub subject: String,
pub body: String,
pub from: Option<Vec<Mailbox>>,
pub cc: Option<Vec<Mailbox>>,
pub bcc: Option<Vec<Mailbox>>,
pub html: bool,
pub attachments: Vec<Attachment>,
}
fn create_send_raw_message(config: &SendConfig) -> Result<String, GwsError> {
let mb = mail_builder::MessageBuilder::new()
.to(to_mb_address_list(&config.to))
.subject(&config.subject);
let mb = apply_optional_headers(
mb,
config.from.as_deref(),
config.cc.as_deref(),
config.bcc.as_deref(),
);
finalize_message(mb, &config.body, config.html, &config.attachments)
}
fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
let to = Mailbox::parse_list(matches.get_one::<String>("to").unwrap());
if to.is_empty() {
return Err(GwsError::Validation(
"--to must specify at least one recipient".to_string(),
));
}
Ok(SendConfig {
to,
subject: matches.get_one::<String>("subject").unwrap().to_string(),
body: matches.get_one::<String>("body").unwrap().to_string(),
from: parse_optional_mailboxes(matches, "from"),
cc: parse_optional_mailboxes(matches, "cc"),
bcc: parse_optional_mailboxes(matches, "bcc"),
html: matches.get_flag("html"),
attachments: parse_attachments(matches)?,
})
}
#[cfg(test)]
mod tests {
use super::super::tests::{extract_header, strip_qp_soft_breaks};
use super::*;
fn make_matches_send(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("to").long("to"))
.arg(Arg::new("subject").long("subject"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("from").long("from"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("attach")
.long("attach")
.short('a')
.action(ArgAction::Append),
)
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_send_args() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.to.len(), 1);
assert_eq!(config.to[0].email, "me@example.com");
assert_eq!(config.subject, "Hi");
assert_eq!(config.body, "Body");
assert!(config.from.is_none());
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_with_from() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
"alias@example.com",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com");
// Whitespace-only --from becomes None
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
" ",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.from.is_none());
}
#[test]
fn test_parse_send_args_with_cc_and_bcc() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--cc",
"carol@example.com",
"--bcc",
"secret@example.com",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com");
assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
// Whitespace-only values become None
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--cc",
" ",
"--bcc",
"",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_html_flag() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"<b>Bold</b>",
"--html",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.html);
// Default is false
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Plain",
]);
let config = parse_send_args(&matches).unwrap();
assert!(!config.html);
}
#[test]
fn test_parse_send_args_empty_to_returns_error() {
let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]);
let err = parse_send_args(&matches).err().unwrap();
assert!(
err.to_string().contains("--to"),
"error should mention --to"
);
}
#[test]
fn test_send_html_raw_message() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "HTML test".to_string(),
body: "<p>Hello <b>world</b></p>".to_string(),
from: None,
cc: None,
bcc: None,
html: true,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let decoded = strip_qp_soft_breaks(&raw);
assert!(decoded.contains("text/html"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject")
.unwrap()
.contains("HTML test"));
assert!(decoded.contains("<p>Hello <b>world</b></p>"));
assert!(extract_header(&raw, "Cc").is_none());
}
#[test]
fn test_send_plain_text_raw_message() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Hello".to_string(),
body: "World".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject").unwrap().contains("Hello"));
assert!(raw.contains("text/plain"));
assert!(raw.contains("World"));
}
#[test]
fn test_send_with_cc_and_bcc() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com")),
bcc: Some(Mailbox::parse_list("secret@example.com")),
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
assert!(extract_header(&raw, "Bcc")
.unwrap()
.contains("secret@example.com"));
// Verify no leakage between headers
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("carol@example.com"));
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("secret@example.com"));
}
#[test]
fn test_send_with_from() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list("alias@example.com")),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From")
.unwrap()
.contains("alias@example.com"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
}
#[test]
fn test_send_without_from_has_no_from_header() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From").is_none());
}
#[test]
fn test_send_multiple_to_recipients() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com, bob@example.com"),
subject: "Group".to_string(),
body: "Hi all".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let to_header = extract_header(&raw, "To").unwrap();
assert!(to_header.contains("alice@example.com"));
assert!(to_header.contains("bob@example.com"));
}
#[test]
fn test_send_crlf_injection_in_from_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list(
"sender@example.com\r\nBcc: evil@attacker.com",
)),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// The CRLF injection should not create a Bcc header
assert!(
extract_header(&raw, "Bcc").is_none(),
"CRLF injection via --from should not create Bcc header"
);
// The From header should contain the sanitized email
assert!(extract_header(&raw, "From")
.unwrap()
.contains("sender@example.com"));
}
#[test]
fn test_send_crlf_injection_in_cc_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")),
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// CRLF stripped → "X-Injected: yes" is concatenated into the email,
// not emitted as a separate header line
assert!(
extract_header(&raw, "X-Injected").is_none(),
"CRLF injection via --cc should not create X-Injected header"
);
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
}
#[test]
fn test_send_with_attachment_produces_multipart() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Report".to_string(),
body: "See attached".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
}],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("report.pdf"));
assert!(raw.contains("See attached"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
}
}
@@ -0,0 +1,305 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Gmail `+triage` helper — lists unread messages with sender, subject, date.
//!
//! Read-only: fetches unread message metadata (From, Subject, Date) and
//! optionally includes label IDs. Supports custom Gmail search queries
//! via `--query` and configurable result limits via `--max`.
use super::*;
/// Handle the `+triage` subcommand.
pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
let max: u32 = matches
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let query = matches
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
let show_labels = matches.get_flag("labels");
let output_format = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
// Authenticate — use gmail.readonly instead of gmail.modify because triage
// is read-only and the `q` query parameter is not supported under the
// gmail.metadata scope. When a token carries both metadata and modify
// scopes the API may resolve to the metadata path and reject `q` with 403.
// gmail.readonly always supports `q`.
let token = auth::get_token(&[GMAIL_READONLY_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
// 1. List message IDs
let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages";
let list_resp = client
.get(list_url)
.query(&[("q", query), ("maxResults", &max.to_string())])
.bearer_auth(&token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list messages: {e}")))?;
if !list_resp.status().is_success() {
let err = list_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 0,
message: err,
reason: "list_failed".to_string(),
enable_url: None,
});
}
let list_json: Value = list_resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse list response: {e}")))?;
let messages = match list_json.get("messages").and_then(|m| m.as_array()) {
Some(m) => m,
None => {
eprintln!("{}", no_messages_msg(query));
return Ok(());
}
};
if messages.is_empty() {
eprintln!("{}", no_messages_msg(query));
return Ok(());
}
// 2. Fetch metadata for each message concurrently
use futures_util::stream::{self, StreamExt};
// Collect message IDs upfront (owned) to avoid lifetime issues in async closures
let msg_ids: Vec<String> = messages
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect();
let results: Vec<Value> = stream::iter(msg_ids)
.map(|msg_id| {
let client = &client;
let token = &token;
async move {
let get_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date",
msg_id
);
let get_resp = crate::client::send_with_retry(|| {
client.get(&get_url).bearer_auth(token)
})
.await
.ok()?;
if !get_resp.status().is_success() {
return None;
}
let msg_json: Value = get_resp.json().await.ok()?;
let headers = msg_json
.get("payload")
.and_then(|p| p.get("headers"))
.and_then(|h| h.as_array());
let mut from = String::new();
let mut subject = String::new();
let mut date = String::new();
if let Some(headers) = headers {
for h in headers {
let name = h.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = h.get("value").and_then(|v| v.as_str()).unwrap_or("");
match name {
"From" => from = value.to_string(),
"Subject" => subject = value.to_string(),
"Date" => date = value.to_string(),
_ => {}
}
}
}
let mut entry = json!({
"id": msg_id,
"from": from,
"subject": subject,
"date": date,
});
if show_labels {
let labels = msg_json
.get("labelIds")
.cloned()
.unwrap_or(Value::Array(vec![]));
entry["labels"] = labels;
}
Some(entry)
}
})
.buffer_unordered(10)
.filter_map(|r| async { r })
.collect()
.await;
// 3. Output
let result_count = results.len();
let output = json!({
"messages": results,
"resultSizeEstimate": list_json.get("resultSizeEstimate").cloned().unwrap_or(json!(result_count)),
"query": query,
});
println!(
"{}",
crate::formatter::format_value(&output, &output_format)
);
Ok(())
}
/// Returns the human-readable "no messages" diagnostic string.
/// Extracted so the test can reference the exact same message without duplication.
fn no_messages_msg(query: &str) -> String {
format!(
"No messages found matching query: {}",
crate::output::sanitize_for_terminal(query)
)
}
#[cfg(test)]
mod tests {
use super::no_messages_msg;
use clap::{Arg, ArgAction, Command};
/// Build a clap command matching the +triage definition so we can
/// unit-test argument parsing without needing a live GmailHelper.
fn triage_cmd() -> Command {
Command::new("triage")
.arg(
Arg::new("max")
.long("max")
.default_value("20")
.value_name("N"),
)
.arg(Arg::new("query").long("query").value_name("QUERY"))
.arg(Arg::new("labels").long("labels").action(ArgAction::SetTrue))
.arg(Arg::new("format").long("format").value_name("FMT"))
}
#[test]
fn defaults_max_to_20_and_query_to_unread() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let query = m
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
assert_eq!(max, 20);
assert_eq!(query, "is:unread");
}
#[test]
fn explicit_max_overrides_default() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--max", "5"])
.unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
assert_eq!(max, 5);
}
#[test]
fn non_numeric_max_falls_back_to_20() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--max", "abc"])
.unwrap();
let max: u32 = m
.get_one::<String>("max")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
assert_eq!(max, 20);
}
#[test]
fn custom_query_overrides_default() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--query", "from:boss"])
.unwrap();
let query = m
.get_one::<String>("query")
.map(|s| s.as_str())
.unwrap_or("is:unread");
assert_eq!(query, "from:boss");
}
#[test]
fn labels_flag_defaults_to_false() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
assert!(!m.get_flag("labels"));
}
#[test]
fn labels_flag_set_when_passed() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--labels"])
.unwrap();
assert!(m.get_flag("labels"));
}
#[test]
fn format_defaults_to_table_when_absent() {
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
let fmt = m
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
assert!(matches!(fmt, crate::formatter::OutputFormat::Table));
}
#[test]
fn format_json_when_specified() {
let m = triage_cmd()
.try_get_matches_from(["triage", "--format", "json"])
.unwrap();
let fmt = m
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
assert!(matches!(fmt, crate::formatter::OutputFormat::Json));
}
#[test]
fn empty_result_message_is_not_json() {
// Verify that no_messages_msg() produces a human-readable string that
// belongs on stderr, not stdout. If it were valid JSON it could corrupt
// pipe workflows like `gws gmail +triage | jq`.
let msg = no_messages_msg("label:inbox");
assert!(serde_json::from_str::<serde_json::Value>(&msg).is_err());
}
}
@@ -0,0 +1,993 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::colorize;
use crate::output::sanitize_for_terminal;
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
/// Handles the `+watch` command — Gmail push notifications via Pub/Sub.
pub(super) async fn handle_watch(
matches: &ArgMatches,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
) -> Result<(), GwsError> {
let config = parse_watch_args(matches)?;
if let Some(ref dir) = config.output_dir {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
}
let client = crate::client::build_client()?;
let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE]);
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
// Get tokens
let gmail_token = auth::get_token(&[GMAIL_SCOPE])
.await
.context("Failed to get Gmail token")?;
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
.await
.context("Failed to get Pub/Sub token")?;
let (pubsub_subscription, topic_name, created_resources) = if let Some(ref sub_name) =
config.subscription
{
(sub_name.clone(), None, false)
} else {
let project = config
.project.clone()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
.ok_or_else(|| {
GwsError::Validation(
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
)
})?;
let suffix = format!("{:08x}", rand::random::<u32>());
let topic = if let Some(ref t) = config.topic {
crate::validate::validate_resource_name(t)?.to_string()
} else {
let project = crate::validate::validate_resource_name(&project)?;
let t = format!("projects/{project}/topics/gws-gmail-watch-{suffix}");
// Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {t}");
let resp = client
.put(format!("{PUBSUB_API_BASE}/{t}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to create topic")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub topic: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// Grant Gmail publish permission on the topic
eprintln!("Granting Gmail push permission on topic...");
let iam_body = json!({
"policy": {
"bindings": [{
"role": "roles/pubsub.publisher",
"members": ["serviceAccount:gmail-api-push@system.gserviceaccount.com"]
}]
}
});
let resp = client
.post(format!("{PUBSUB_API_BASE}/{t}:setIamPolicy"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&iam_body)
.send()
.await
.context("Failed to set topic IAM policy")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
eprintln!("Warning: Could not auto-grant Gmail push permission.");
eprintln!("You may need to manually grant publisher access:");
eprintln!(
" gcloud pubsub topics add-iam-policy-binding {} \\",
t.split('/').rfind(|s| !s.is_empty()).unwrap_or(&t)
);
eprintln!(
" --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\"
);
eprintln!(" --role=roles/pubsub.publisher");
eprintln!("Error: {}", sanitize_for_terminal(&body));
}
t
};
let project = crate::validate::validate_resource_name(&project)?;
let sub = format!("projects/{project}/subscriptions/gws-gmail-watch-{suffix}");
// 3. Create Pub/Sub subscription
eprintln!("Creating Pub/Sub subscription: {sub}");
let sub_body = json!({
"topic": topic,
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&sub_body)
.send()
.await
.context("Failed to create subscription")?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Failed to create Pub/Sub subscription: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
// 4. Call gmail.users.watch
eprintln!("Setting up Gmail watch...");
let mut watch_body = json!({
"topicName": topic,
});
if let Some(ref label_ids) = config.label_ids {
let labels: Vec<&str> = label_ids.split(',').map(|s| s.trim()).collect();
watch_body["labelIds"] = json!(labels);
}
let resp = client
.post(format!("{GMAIL_API_BASE}/users/me/watch"))
.bearer_auth(&gmail_token)
.header("Content-Type", "application/json")
.json(&watch_body)
.send()
.await
.context("Failed to call gmail.users.watch")?;
let watch_resp: Value = resp
.json()
.await
.context("Failed to parse watch response")?;
if let Some(err) = watch_resp.get("error") {
return Err(GwsError::Api {
code: err.get("code").and_then(|c| c.as_u64()).unwrap_or(400) as u16,
message: format!(
"gmail.users.watch failed: {}",
serde_json::to_string(err).unwrap_or_default()
),
reason: "gmailError".to_string(),
enable_url: None,
});
}
let history_id = watch_resp
.get("historyId")
.and_then(|h| h.as_str())
.unwrap_or("0");
let expiration = watch_resp
.get("expiration")
.and_then(|e| e.as_str())
.unwrap_or("unknown");
eprintln!("Gmail watch active (historyId: {history_id}, expires: {expiration})");
eprintln!("Listening for new emails...\n");
(sub, Some(topic), true)
};
// Get initial historyId for tracking
let profile_resp = client
.get(format!("{GMAIL_API_BASE}/users/me/profile"))
.bearer_auth(&gmail_token)
.send()
.await
.context("Failed to get Gmail profile")?;
let profile: Value = profile_resp.json().await.unwrap_or(json!({}));
let mut last_history_id: u64 = profile
.get("historyId")
.and_then(|h| h.as_str().or_else(|| h.as_u64().map(|_| "")))
.and_then(|s| s.parse().ok())
.or_else(|| profile.get("historyId").and_then(|h| h.as_u64()))
.unwrap_or(0);
// Pull loop
let runtime = WatchRuntime {
client: &client,
pubsub_token_provider: &pubsub_token_provider,
gmail_token_provider: &gmail_token_provider,
sanitize_config,
pubsub_api_base: PUBSUB_API_BASE,
gmail_api_base: GMAIL_API_BASE,
};
let result = watch_pull_loop(
&runtime,
&pubsub_subscription,
&mut last_history_id,
config.clone(),
)
.await;
// Cleanup or print reconnection info
if created_resources {
if config.cleanup {
eprintln!("\nCleaning up Pub/Sub resources...");
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{}", pubsub_subscription))
.bearer_auth(&pubsub_token)
.send()
.await;
if let Some(ref topic) = topic_name {
let _ = client
.delete(format!("{PUBSUB_API_BASE}/{}", topic))
.bearer_auth(&pubsub_token)
.send()
.await;
}
eprintln!("Cleanup complete.");
} else {
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
}
} else {
eprintln!("\n--- Reconnection Info ---");
eprintln!(
"To reconnect later:\n gws gmail +watch --subscription {}",
pubsub_subscription
);
if let Some(ref topic) = topic_name {
eprintln!("Pub/Sub topic: {}", topic);
}
eprintln!("Pub/Sub subscription: {}", pubsub_subscription);
eprintln!("Note: Gmail watch expires after 7 days. Re-run +watch to renew.");
}
}
result
}
/// Pull loop for Gmail watch — polls Pub/Sub, fetches messages via history API.
async fn watch_pull_loop(
runtime: &WatchRuntime<'_>,
subscription: &str,
last_history_id: &mut u64,
config: WatchConfig,
) -> Result<(), GwsError> {
loop {
let pubsub_token = runtime
.pubsub_token_provider
.access_token()
.await
.context("Failed to get Pub/Sub token")?;
let pull_body = json!({ "maxMessages": config.max_messages });
let pull_future = runtime
.client
.post(format!("{}/{subscription}:pull", runtime.pubsub_api_base))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&pull_body)
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
.send();
let resp = tokio::select! {
result = pull_future => {
match result {
Ok(r) => r,
Err(e) if e.is_timeout() => continue,
Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))),
}
}
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: 400,
message: format!("Pub/Sub pull failed: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
let (ack_ids, max_history_id) = process_pull_response(&pull_response);
if max_history_id > *last_history_id && *last_history_id > 0 {
// Fetch new messages via history API
fetch_and_output_messages(
runtime.client,
runtime.gmail_token_provider,
*last_history_id,
&config.format,
config.output_dir.as_ref(),
runtime.sanitize_config,
runtime.gmail_api_base,
)
.await?;
}
if max_history_id > *last_history_id {
*last_history_id = max_history_id;
}
// Acknowledge messages
if !ack_ids.is_empty() {
let ack_body = json!({ "ackIds": ack_ids });
let _ = runtime
.client
.post(format!(
"{}/{subscription}:acknowledge",
runtime.pubsub_api_base
))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&ack_body)
.send()
.await;
}
if config.once {
break;
}
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
}
Ok(())
}
fn process_pull_response(response: &Value) -> (Vec<String>, u64) {
let mut ack_ids = Vec::new();
let mut max_history_id = 0;
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
for msg in messages {
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
ack_ids.push(ack_id.to_string());
}
// Extract historyId from the notification
if let Some(pubsub_msg) = msg.get("message") {
let data = pubsub_msg
.get("data")
.and_then(|d| d.as_str())
.and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
if let Some(notification) = data {
let notif_history_id = notification
.get("historyId")
.and_then(|h| h.as_u64().or_else(|| h.as_str()?.parse().ok()))
.unwrap_or(0);
if notif_history_id > max_history_id {
max_history_id = notif_history_id;
}
}
}
}
}
(ack_ids, max_history_id)
}
/// Fetches new messages since `start_history_id` and outputs them as NDJSON.
async fn fetch_and_output_messages(
client: &reqwest::Client,
gmail_token_provider: &dyn auth::AccessTokenProvider,
start_history_id: u64,
msg_format: &str,
output_dir: Option<&std::path::PathBuf>,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
gmail_api_base: &str,
) -> Result<(), GwsError> {
let gmail_token = gmail_token_provider
.access_token()
.await
.context("Failed to get Gmail token")?;
let resp = client
.get(format!("{gmail_api_base}/users/me/history"))
.query(&[
("startHistoryId", &start_history_id.to_string()),
("historyTypes", &"messageAdded".to_string()),
])
.bearer_auth(&gmail_token)
.send()
.await
.context("Failed to get history")?;
let body: Value = resp.json().await.unwrap_or(json!({}));
let msg_ids = extract_message_ids_from_history(&body);
for msg_id in msg_ids {
let msg_url = format!(
"{gmail_api_base}/users/me/messages/{}",
crate::validate::encode_path_segment(&msg_id),
);
let msg_resp = client
.get(&msg_url)
.query(&[("format", msg_format)])
.bearer_auth(&gmail_token)
.send()
.await;
if let Ok(resp) = msg_resp {
if let Ok(mut full_msg) = resp.json::<Value>().await {
// Apply sanitization if configured
if let Some(ref template) = sanitize_config.template {
let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default();
match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await
{
Ok(result) => {
if let Some(sanitized_msg) = apply_sanitization_result(
full_msg,
sanitize_config,
&result,
&msg_id,
) {
full_msg = sanitized_msg;
} else {
continue;
}
}
Err(e) => {
eprintln!(
"{} Model Armor sanitization failed for message {msg_id}: {}",
colorize("warning:", "33"),
sanitize_for_terminal(&e.to_string())
);
}
}
}
let json_str =
serde_json::to_string_pretty(&full_msg).unwrap_or_else(|_| "{}".to_string());
if let Some(dir) = output_dir {
let path = dir.join(format!(
"{}.json",
crate::validate::encode_path_segment(&msg_id)
));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!(
"Warning: failed to write {}: {}",
path.display(),
sanitize_for_terminal(&e.to_string())
);
} else {
eprintln!("Wrote {}", path.display());
}
} else {
println!(
"{}",
serde_json::to_string(&full_msg).unwrap_or_else(|_| "{}".to_string())
);
}
}
}
}
Ok(())
}
fn apply_sanitization_result(
mut full_msg: Value,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
result: &crate::helpers::modelarmor::SanitizationResult,
msg_id: &str,
) -> Option<Value> {
if result.filter_match_state == "MATCH_FOUND" {
match sanitize_config.mode {
crate::helpers::modelarmor::SanitizeMode::Block => {
eprintln!(
"{} Message {msg_id} blocked by Model Armor (match found)",
colorize("blocked:", "31")
);
return None;
}
crate::helpers::modelarmor::SanitizeMode::Warn => {
eprintln!(
"{} Model Armor match found in message {msg_id}",
colorize("warning:", "33")
);
full_msg["_sanitization"] = serde_json::json!({
"filterMatchState": result.filter_match_state,
"filterResults": result.filter_results,
});
}
}
}
Some(full_msg)
}
fn extract_message_ids_from_history(history_body: &Value) -> Vec<String> {
let mut seen_ids = std::collections::HashSet::new();
let mut result = Vec::new();
if let Some(history) = history_body.get("history").and_then(|h| h.as_array()) {
for entry in history {
if let Some(added) = entry.get("messagesAdded").and_then(|m| m.as_array()) {
for msg_entry in added {
if let Some(msg_id) = msg_entry
.get("message")
.and_then(|m| m.get("id"))
.and_then(|id| id.as_str())
{
if seen_ids.insert(msg_id.to_string()) {
result.push(msg_id.to_string());
}
}
}
}
}
}
result
}
#[derive(Debug, Clone)]
struct WatchConfig {
project: Option<String>,
subscription: Option<String>,
topic: Option<String>,
label_ids: Option<String>,
max_messages: u32,
poll_interval: u64,
format: String,
once: bool,
cleanup: bool,
output_dir: Option<std::path::PathBuf>,
}
struct WatchRuntime<'a> {
client: &'a reqwest::Client,
pubsub_token_provider: &'a dyn auth::AccessTokenProvider,
gmail_token_provider: &'a dyn auth::AccessTokenProvider,
sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
pubsub_api_base: &'a str,
gmail_api_base: &'a str,
}
fn parse_watch_args(matches: &ArgMatches) -> Result<WatchConfig, GwsError> {
let format_str = matches
.get_one::<String>("msg-format")
.map(|s| s.as_str())
.unwrap_or("full");
// Note: msg-format is already constrained by clap's value_parser
let output_dir = matches
.get_one::<String>("output-dir")
.map(|dir| crate::validate::validate_safe_output_dir(dir))
.transpose()?;
Ok(WatchConfig {
project: matches.get_one::<String>("project").cloned(),
subscription: matches
.get_one::<String>("subscription")
.map(|s| {
crate::validate::validate_resource_name(s)?;
Ok::<_, GwsError>(s.clone())
})
.transpose()?,
topic: matches.get_one::<String>("topic").cloned(),
label_ids: matches.get_one::<String>("label-ids").cloned(),
max_messages: matches
.get_one::<String>("max-messages")
.and_then(|s| s.parse().ok())
.unwrap_or(10),
poll_interval: matches
.get_one::<String>("poll-interval")
.and_then(|s| s.parse().ok())
.unwrap_or(5),
format: format_str.to_string(),
once: matches.get_flag("once"),
cleanup: matches.get_flag("cleanup"),
output_dir,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::FakeTokenProvider;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn spawn_watch_server() -> (
String,
String,
Arc<Mutex<Vec<(String, String)>>>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded_requests = Arc::clone(&requests);
let handle = tokio::spawn(async move {
for _ in 0..4 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0_u8; 8192];
let bytes_read = stream.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..bytes_read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let auth_header = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.unwrap_or("")
.trim()
.to_string();
recorded_requests
.lock()
.await
.push((path.clone(), auth_header));
let body = match path.as_str() {
"/v1/projects/test/subscriptions/demo:pull" => {
let payload = base64::engine::general_purpose::STANDARD
.encode(json!({ "historyId": 2 }).to_string());
json!({
"receivedMessages": [{
"ackId": "ack-1",
"message": {
"data": payload,
"messageId": "msg-1"
}
}]
})
.to_string()
}
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded" => {
json!({
"history": [{
"messagesAdded": [{
"message": { "id": "msg-1" }
}]
}]
})
.to_string()
}
"/gmail/v1/users/me/messages/msg%2D1?format=full" => {
json!({ "id": "msg-1" }).to_string()
}
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
other => panic!("unexpected request path: {other}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
(
format!("http://{addr}/v1"),
format!("http://{addr}/gmail/v1"),
requests,
handle,
)
}
#[test]
fn test_extract_message_ids_from_history() {
let history = json!({
"history": [
{
"messagesAdded": [
{ "message": { "id": "msg1", "threadId": "t1" } }
]
},
{
"messagesAdded": [
{ "message": { "id": "msg2", "threadId": "t2" } },
{ "message": { "id": "msg1", "threadId": "t1" } } // duplicate
]
}
]
});
let ids = extract_message_ids_from_history(&history);
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"msg1".to_string()));
assert!(ids.contains(&"msg2".to_string()));
}
#[test]
fn test_extract_message_ids_empty() {
let history = json!({});
let ids = extract_message_ids_from_history(&history);
assert!(ids.is_empty());
}
#[test]
fn test_process_pull_response() {
let encoded_data = URL_SAFE
.encode(json!({ "emailAddress": "me@example.com", "historyId": 12345 }).to_string());
let response = json!({
"receivedMessages": [
{
"ackId": "ack1",
"message": {
"data": encoded_data,
"messageId": "msg1"
}
},
{
"ackId": "ack2",
"message": {
"data": URL_SAFE.encode(json!({ "historyId": 100 }).to_string()),
"messageId": "msg2"
}
}
]
});
let (ack_ids, max_history) = process_pull_response(&response);
assert_eq!(ack_ids.len(), 2);
assert!(ack_ids.contains(&"ack1".to_string()));
assert!(ack_ids.contains(&"ack2".to_string()));
assert_eq!(max_history, 12345);
}
fn make_matches_watch(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("project").long("project"))
.arg(Arg::new("subscription").long("subscription"))
.arg(Arg::new("topic").long("topic"))
.arg(Arg::new("label-ids").long("label-ids"))
.arg(Arg::new("max-messages").long("max-messages"))
.arg(Arg::new("poll-interval").long("poll-interval"))
.arg(Arg::new("msg-format").long("msg-format"))
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
.arg(
Arg::new("cleanup")
.long("cleanup")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("output-dir").long("output-dir"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_watch_args_invalid_format_rejected_by_clap() {
// msg-format is constrained by clap's value_parser, so invalid values
// are rejected at the clap level before parse_watch_args is called.
// Verify the real command definition rejects bad formats:
let helper = super::super::GmailHelper;
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(Command::new("test"), &doc);
let watch_cmd = cmd
.get_subcommands()
.find(|c| c.get_name() == "+watch")
.unwrap()
.clone();
let result =
watch_cmd.try_get_matches_from(vec!["+watch", "--msg-format", "invalid-format"]);
assert!(result.is_err());
}
#[test]
fn test_parse_watch_args_invalid_output_dir() {
let matches = make_matches_watch(&["test", "--output-dir", "../../etc"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_watch_args_rejects_traversal_subscription() {
let matches = make_matches_watch(&["test", "--subscription", "../../evil"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("path traversal"));
}
#[test]
fn test_parse_watch_args_full() {
let matches = make_matches_watch(&[
"test",
"--project",
"p1",
"--subscription",
"s1",
"--max-messages",
"20",
"--once",
]);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project.unwrap(), "p1");
assert_eq!(config.subscription.unwrap(), "s1");
assert_eq!(config.max_messages, 20);
assert!(config.once);
assert!(!config.cleanup);
// Default check handled by unwrap_or
assert_eq!(config.poll_interval, 5);
assert_eq!(config.format, "full");
assert_eq!(config.label_ids, None);
assert_eq!(config.topic, None);
assert_eq!(config.output_dir, None);
}
#[test]
fn test_parse_watch_args_defaults() {
let matches = make_matches_watch(&["test"]);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project, None);
assert_eq!(config.subscription, None);
assert_eq!(config.max_messages, 10);
assert_eq!(config.poll_interval, 5);
assert_eq!(config.format, "full");
assert!(!config.once);
assert!(!config.cleanup);
}
#[test]
fn test_parse_watch_args_invalid_numbers() {
let matches = make_matches_watch(&[
"test",
"--max-messages",
"not_a_number",
"--poll-interval",
"invalid",
]);
let config = parse_watch_args(&matches).unwrap();
// Should fallback to defaults
assert_eq!(config.max_messages, 10);
assert_eq!(config.poll_interval, 5);
}
#[test]
fn test_apply_sanitization_result_block_mode() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Block,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg, &config, &result, "msg1");
assert!(output.is_none());
}
#[test]
fn test_apply_sanitization_result_warn_mode() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg, &config, &result, "msg1").unwrap();
// Warn mode adds the `_sanitization` metadata.
assert!(output.get("_sanitization").is_some());
assert_eq!(output["_sanitization"]["filterMatchState"], "MATCH_FOUND");
}
#[test]
fn test_apply_sanitization_result_no_match() {
let msg = json!({ "id": "msg1" });
let config = crate::helpers::modelarmor::SanitizeConfig {
template: Some("projects/x/locations/y/templates/z".to_string()),
mode: crate::helpers::modelarmor::SanitizeMode::Block,
};
let result = crate::helpers::modelarmor::SanitizationResult {
filter_match_state: "NO_MATCH_FOUND".to_string(),
filter_results: json!([]),
invocation_result: "{}".to_string(),
};
let output = apply_sanitization_result(msg.clone(), &config, &result, "msg1").unwrap();
// If no match found, block mode returns the exact input untouched.
assert_eq!(output, msg);
assert!(output.get("_sanitization").is_none());
}
#[tokio::test]
async fn test_watch_pull_loop_refreshes_tokens_for_each_request() {
let client = reqwest::Client::new();
let pubsub_provider = FakeTokenProvider::new(["pubsub-token"]);
let gmail_provider = FakeTokenProvider::new(["gmail-token"]);
let (pubsub_base, gmail_base, requests, server) = spawn_watch_server().await;
let mut last_history_id = 1;
let config = WatchConfig {
project: None,
subscription: None,
topic: None,
label_ids: None,
max_messages: 10,
poll_interval: 1,
format: "full".to_string(),
once: true,
cleanup: false,
output_dir: None,
};
let sanitize_config = crate::helpers::modelarmor::SanitizeConfig {
template: None,
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
};
let runtime = WatchRuntime {
client: &client,
pubsub_token_provider: &pubsub_provider,
gmail_token_provider: &gmail_provider,
sanitize_config: &sanitize_config,
pubsub_api_base: &pubsub_base,
gmail_api_base: &gmail_base,
};
watch_pull_loop(
&runtime,
"projects/test/subscriptions/demo",
&mut last_history_id,
config,
)
.await
.unwrap();
server.await.unwrap();
let requests = requests.lock().await;
assert_eq!(requests.len(), 4);
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
assert_eq!(
requests[1].0,
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded"
);
assert_eq!(requests[1].1, "authorization: Bearer gmail-token");
assert_eq!(
requests[2].0,
"/gmail/v1/users/me/messages/msg%2D1?format=full"
);
assert_eq!(requests[2].1, "authorization: Bearer gmail-token");
assert_eq!(
requests[3].0,
"/v1/projects/test/subscriptions/demo:acknowledge"
);
assert_eq!(requests[3].1, "authorization: Bearer pubsub-token");
assert_eq!(last_history_id, 2);
}
}
@@ -0,0 +1,128 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::error::GwsError;
use clap::{ArgMatches, Command};
use std::future::Future;
use std::pin::Pin;
pub mod calendar;
pub mod chat;
pub mod docs;
pub mod drive;
pub mod events;
pub mod gmail;
pub mod modelarmor;
pub mod script;
pub mod sheets;
pub mod workflows;
/// Base URL for the Google Cloud Pub/Sub v1 API.
///
/// Shared across `events::subscribe` and `gmail::watch` so the constant
/// is defined in a single place.
pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
/// Returns a future that completes when a shutdown signal is received.
///
/// On Unix this listens for both SIGINT (Ctrl+C) and SIGTERM; on other
/// platforms only SIGINT is handled. Used by long-running pull loops
/// (`gmail::watch`, `events::subscribe`) to exit cleanly under container
/// orchestrators (Kubernetes, Docker, systemd) that send SIGTERM.
///
/// The signal handler is registered once in a background task on first call
/// so it remains active for the lifetime of the process — no gap between
/// loop iterations.
pub(crate) async fn shutdown_signal() {
use std::sync::OnceLock;
use tokio::sync::Notify;
static NOTIFY: OnceLock<std::sync::Arc<Notify>> = OnceLock::new();
let notify = NOTIFY.get_or_init(|| {
let n = std::sync::Arc::new(Notify::new());
let n2 = n.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
res = tokio::signal::ctrl_c() => {
res.expect("failed to listen for SIGINT");
}
Some(_) = sigterm.recv() => {}
}
}
Err(e) => {
eprintln!(
"warning: could not register SIGTERM handler: {e}. \
Listening for Ctrl+C only."
);
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
n2.notify_waiters();
});
n
});
notify.notified().await;
}
/// A trait for service-specific CLI helpers that inject custom commands.
pub trait Helper: Send + Sync {
/// Injects subcommands into the service command.
fn inject_commands(&self, cmd: Command, doc: &crate::discovery::RestDescription) -> Command;
/// Attempts to handle a command. Returns Ok(Some(())) if handled,
/// Ok(None) if not handled (should fall back to dynamic dispatch),
/// or Err if handled but failed.
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
sanitize_config: &'a modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>>;
/// If true, only helper commands are shown (discovery-generated commands are suppressed).
fn helper_only(&self) -> bool {
false
}
}
pub fn get_helper(service: &str) -> Option<Box<dyn Helper>> {
match service {
"gmail" => Some(Box::new(gmail::GmailHelper)),
"sheets" => Some(Box::new(sheets::SheetsHelper)),
"docs" => Some(Box::new(docs::DocsHelper)),
"chat" => Some(Box::new(chat::ChatHelper)),
"drive" => Some(Box::new(drive::DriveHelper)),
"calendar" => Some(Box::new(calendar::CalendarHelper)),
"script" | "apps-script" => Some(Box::new(script::ScriptHelper)),
"workspaceevents" => Some(Box::new(events::EventsHelper)),
"modelarmor" => Some(Box::new(modelarmor::ModelArmorHelper)),
"workflow" => Some(Box::new(workflows::WorkflowHelper)),
_ => None,
}
}
@@ -0,0 +1,780 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::discovery::RestDescription;
use crate::error::GwsError;
use anyhow::Context;
use clap::{Arg, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
/// Result of a Model Armor sanitization check.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SanitizationResult {
/// The overall state of the match (e.g., "MATCH_FOUND", "NO_MATCH_FOUND").
pub filter_match_state: String,
/// Detailed results from specific filters (PI, Jailbreak, etc.).
#[serde(default)]
pub filter_results: serde_json::Value,
/// The final decision based on the policy (e.g., "BLOCK", "ALLOW").
#[serde(default)]
pub invocation_result: String,
}
/// Controls behavior when sanitization finds a match.
#[derive(Debug, Clone, PartialEq)]
pub enum SanitizeMode {
/// Log warning to stderr, annotate output with _sanitization field
Warn,
/// Suppress response output, exit non-zero
Block,
}
/// Configuration for Model Armor sanitization, threaded through the CLI.
#[derive(Debug, Clone)]
pub struct SanitizeConfig {
pub template: Option<String>,
pub mode: SanitizeMode,
}
impl Default for SanitizeConfig {
/// Provides default values for `SanitizeConfig`.
///
/// By default, no template is set (sanitization disabled) and the mode is `Warn`.
fn default() -> Self {
Self {
template: None,
mode: SanitizeMode::Warn,
}
}
}
impl SanitizeMode {
/// Parses a string into a `SanitizeMode`.
///
/// * "block" (case-insensitive) -> `Block`
/// * Any other value -> `Warn` (safe default)
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"block" => SanitizeMode::Block,
_ => SanitizeMode::Warn,
}
}
}
pub struct ModelArmorHelper;
/// Build the regional base URL for Model Armor API.
/// The discovery doc rootUrl (modelarmor.us.rep.googleapis.com) is incorrect —
/// Model Armor requires region-specific endpoints: modelarmor.{region}.rep.googleapis.com
fn regional_base_url(location: &str) -> String {
format!("https://modelarmor.{location}.rep.googleapis.com/v1")
}
/// Extract location from a full template resource name.
/// e.g. "projects/my-project/locations/us-central1/templates/my-template" -> "us-central1"
fn extract_location(resource_name: &str) -> Option<&str> {
let parts: Vec<&str> = resource_name.split('/').collect();
for i in 0..parts.len() {
if parts[i] == "locations" && i + 1 < parts.len() {
return Some(parts[i + 1]);
}
}
None
}
impl Helper for ModelArmorHelper {
fn inject_commands(&self, mut cmd: Command, _doc: &RestDescription) -> Command {
cmd = cmd.subcommand(
Command::new("+sanitize-prompt")
.about("[Helper] Sanitize a user prompt through a Model Armor template")
.arg(
Arg::new("template")
.long("template")
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text content to sanitize")
.value_name("TEXT"),
)
.arg(
Arg::new("json")
.long("json")
.help("Full JSON request body (overrides --text)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +sanitize-prompt --template projects/P/locations/L/templates/T --text 'user input'
echo 'prompt' | gws modelarmor +sanitize-prompt --template ...
TIPS:
If neither --text nor --json is given, reads from stdin.
For outbound safety, use +sanitize-response instead."),
);
cmd = cmd.subcommand(
Command::new("+sanitize-response")
.about("[Helper] Sanitize a model response through a Model Armor template")
.arg(
Arg::new("template")
.long("template")
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("text")
.long("text")
.help("Text content to sanitize")
.value_name("TEXT"),
)
.arg(
Arg::new("json")
.long("json")
.help("Full JSON request body (overrides --text)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +sanitize-response --template projects/P/locations/L/templates/T --text 'model output'
model_cmd | gws modelarmor +sanitize-response --template ...
TIPS:
Use for outbound safety (model -> user).
For inbound safety (user -> model), use +sanitize-prompt."),
);
cmd = cmd.subcommand(
Command::new("+create-template")
.about("[Helper] Create a new Model Armor template")
.arg(
Arg::new("project")
.long("project")
.help("GCP project ID")
.required(true)
.value_name("PROJECT"),
)
.arg(
Arg::new("location")
.long("location")
.help("GCP location (e.g. us-central1)")
.required(true)
.value_name("LOCATION"),
)
.arg(
Arg::new("template-id")
.long("template-id")
.help("Template ID to create")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("preset")
.long("preset")
.help("Use a preset template: jailbreak")
.value_name("PRESET")
.value_parser(["jailbreak"]),
)
.arg(
Arg::new("json")
.long("json")
.help("JSON body for the template configuration (overrides --preset)")
.value_name("JSON"),
)
.after_help("\
EXAMPLES:
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --preset jailbreak
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --json '{...}'
TIPS:
Defaults to the jailbreak preset if neither --preset nor --json is given.
Use the resulting template name with +sanitize-prompt and +sanitize-response."),
);
cmd
}
fn helper_only(&self) -> bool {
true
}
fn handle<'a>(
&'a self,
_doc: &'a RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") {
handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData").await?;
return Ok(true);
}
if let Some(sub) = matches.subcommand_matches("+sanitize-response") {
handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData").await?;
return Ok(true);
}
if let Some(sub) = matches.subcommand_matches("+create-template") {
handle_create_template(sub).await?;
return Ok(true);
}
Ok(false)
})
}
}
pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
/// Sanitize text through a Model Armor template and return the result.
/// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE
pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationResult, GwsError> {
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
.await
.context("Failed to get auth token for Model Armor")?;
let client = crate::client::build_client()?;
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.context("Model Armor request failed")?;
let status = resp.status();
let resp_text = resp
.text()
.await
.context("Failed to read Model Armor response")?;
if !status.is_success() {
return Err(GwsError::Other(anyhow::anyhow!(
"Model Armor API returned status {status}: {resp_text}"
)));
}
parse_sanitize_response(&resp_text)
}
/// Make a POST request to Model Armor's regional API endpoint.
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
.await
.context("Failed to get auth token")?;
let client = crate::client::build_client()?;
let resp = client
.post(url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.await
.context("HTTP request failed")?;
let status = resp.status();
let text = resp.text().await.context("Failed to read response")?;
if !status.is_success() {
return Err(GwsError::Other(anyhow::anyhow!(
"API returned status {status}: {text}"
)));
}
println!("{text}");
Ok(())
}
/// Handle +sanitize-prompt and +sanitize-response
async fn handle_sanitize(
matches: &ArgMatches,
method_name: &str,
data_field: &str,
) -> Result<(), GwsError> {
let template_raw = matches.get_one::<String>("template").unwrap();
let template = crate::validate::validate_resource_name(template_raw)?;
let location = extract_location(template).ok_or_else(|| {
GwsError::Validation(
"Cannot extract location from template name. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
)
})?;
let body = parse_sanitize_args(matches, data_field)?;
let base = regional_base_url(location);
let url = format!("{base}/{template}:{method_name}");
model_armor_post(&url, &body).await
}
#[derive(Debug, PartialEq)]
pub struct CreateTemplateConfig {
pub project: String,
pub location: String,
pub template_id: String,
pub body: String,
}
fn parse_create_template_args(matches: &ArgMatches) -> Result<CreateTemplateConfig, GwsError> {
let project_raw = matches.get_one::<String>("project").unwrap();
let project = crate::validate::validate_resource_name(project_raw)?.to_string();
let location_raw = matches.get_one::<String>("location").unwrap();
let location = crate::validate::validate_resource_name(location_raw)?.to_string();
let template_id_raw = matches.get_one::<String>("template-id").unwrap();
let template_id = crate::validate::validate_resource_name(template_id_raw)?.to_string();
let body = if let Some(json_str) = matches.get_one::<String>("json") {
json_str.clone()
} else {
let preset = matches
.get_one::<String>("preset")
.map(|s| s.as_str())
.unwrap_or("jailbreak");
load_preset_template(preset)?
};
Ok(CreateTemplateConfig {
project,
location,
template_id,
body,
})
}
pub fn build_create_template_url(config: &CreateTemplateConfig) -> String {
let base = regional_base_url(&config.location);
let project = crate::validate::encode_path_segment(&config.project);
let location = crate::validate::encode_path_segment(&config.location);
let parent = format!("projects/{project}/locations/{location}");
format!(
"{base}/{parent}/templates?templateId={}",
crate::validate::encode_path_segment(&config.template_id)
)
}
/// Handle +create-template
async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> {
let config = parse_create_template_args(matches)?;
let url = build_create_template_url(&config);
eprintln!(
"Creating template '{}' with preset: {}",
config.template_id,
matches
.get_one::<String>("preset")
.map(|s| s.as_str())
.unwrap_or("jailbreak")
);
model_armor_post(&url, &config.body).await
}
/// Loads a preset template JSON file from the templates/modelarmor/ directory.
/// Falls back to the embedded template if the file is not found.
fn load_preset_template(name: &str) -> Result<String, GwsError> {
// Try to find templates relative to the executable
let exe_path = std::env::current_exe().ok();
let search_dirs: Vec<std::path::PathBuf> = [
// Relative to current directory
Some(std::path::PathBuf::from("templates/modelarmor")),
// Relative to executable
exe_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.join("../templates/modelarmor")),
exe_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.join("templates/modelarmor")),
]
.into_iter()
.flatten()
.collect();
let filename = format!("{name}.json");
for dir in &search_dirs {
let path = dir.join(&filename);
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read template '{}'", path.display()))?;
eprintln!("Using preset template from: {}", path.display());
return Ok(content);
}
}
// Fallback: embedded preset
eprintln!("Template file not found, using embedded '{}' preset", name);
Ok(include_str!("../../templates/modelarmor/jailbreak.json").to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_config_default() {
let config = SanitizeConfig::default();
assert!(config.template.is_none());
assert_eq!(config.mode, SanitizeMode::Warn);
}
#[test]
fn test_sanitize_config_with_template() {
let config = SanitizeConfig {
template: Some("projects/p/locations/us-central1/templates/t".to_string()),
mode: SanitizeMode::Block,
};
assert_eq!(
config.template.as_deref(),
Some("projects/p/locations/us-central1/templates/t")
);
assert_eq!(config.mode, SanitizeMode::Block);
}
#[test]
fn test_sanitize_mode_from_str_warn() {
assert_eq!(SanitizeMode::from_str("warn"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("WARN"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("Warn"), SanitizeMode::Warn);
}
#[test]
fn test_sanitize_mode_from_str_block() {
assert_eq!(SanitizeMode::from_str("block"), SanitizeMode::Block);
assert_eq!(SanitizeMode::from_str("BLOCK"), SanitizeMode::Block);
assert_eq!(SanitizeMode::from_str("Block"), SanitizeMode::Block);
}
#[test]
fn test_sanitize_mode_from_str_unknown_defaults_to_warn() {
assert_eq!(SanitizeMode::from_str(""), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("invalid"), SanitizeMode::Warn);
assert_eq!(SanitizeMode::from_str("stop"), SanitizeMode::Warn);
}
#[test]
fn test_extract_location_valid() {
assert_eq!(
extract_location("projects/my-project/locations/us-central1/templates/my-template"),
Some("us-central1")
);
}
#[test]
fn test_extract_location_different_region() {
assert_eq!(
extract_location("projects/p/locations/europe-west1/templates/t"),
Some("europe-west1")
);
}
#[test]
fn test_extract_location_no_locations() {
assert_eq!(extract_location("projects/my-project/templates/t"), None);
}
#[test]
fn test_extract_location_empty() {
assert_eq!(extract_location(""), None);
}
#[test]
fn test_extract_location_trailing_locations() {
// "locations" at the end with no value after
assert_eq!(extract_location("projects/p/locations"), None);
}
#[test]
fn test_regional_base_url() {
assert_eq!(
regional_base_url("us-central1"),
"https://modelarmor.us-central1.rep.googleapis.com/v1"
);
}
#[test]
fn test_regional_base_url_different_region() {
assert_eq!(
regional_base_url("europe-west1"),
"https://modelarmor.europe-west1.rep.googleapis.com/v1"
);
}
#[test]
fn test_cloud_platform_scope_constant() {
assert_eq!(
CLOUD_PLATFORM_SCOPE,
"https://www.googleapis.com/auth/cloud-platform"
);
}
#[test]
fn test_build_sanitize_request_data() {
let template = "projects/p/locations/us-central1/templates/t";
let (body, _) =
build_sanitize_request_data(template, "some text", "sanitizeUserPrompt").unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["userPromptData"]["text"], "some text");
}
#[test]
fn test_parse_sanitize_response_success() {
let json_resp = json!({
"sanitizationResult": {
"filterMatchState": "MATCH_FOUND",
"filterResults": {},
"invocationResult": "SUCCESS"
}
})
.to_string();
let res = parse_sanitize_response(&json_resp).unwrap();
assert_eq!(res.filter_match_state, "MATCH_FOUND");
}
#[test]
fn test_parse_sanitize_response_missing_field() {
let json_resp = json!({}).to_string();
assert!(parse_sanitize_response(&json_resp).is_err());
}
}
pub fn build_sanitize_request_data(
template: &str,
text: &str,
method: &str,
) -> Result<(String, String), GwsError> {
let location = extract_location(template).ok_or_else(|| {
GwsError::Validation(
"Cannot extract location from --sanitize template. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
)
})?;
let base = regional_base_url(location);
let url = format!("{base}/{template}:{method}");
// Identify data field based on method
let data_field = if method == "sanitizeUserPrompt" {
"userPromptData"
} else {
"modelResponseData"
};
let body = json!({data_field: {"text": text}}).to_string();
Ok((body, url))
}
pub fn parse_sanitize_response(resp_text: &str) -> Result<SanitizationResult, GwsError> {
// Parse the response to extract sanitizationResult
let parsed: serde_json::Value =
serde_json::from_str(resp_text).context("Failed to parse Model Armor response")?;
let result = parsed.get("sanitizationResult").ok_or_else(|| {
GwsError::Other(anyhow::anyhow!(
"No sanitizationResult in Model Armor response"
))
})?;
let res =
serde_json::from_value(result.clone()).context("Failed to parse sanitization result")?;
Ok(res)
}
fn parse_sanitize_args(matches: &ArgMatches, data_field: &str) -> Result<String, GwsError> {
if let Some(json_str) = matches.get_one::<String>("json") {
Ok(json_str.clone())
} else if let Some(text) = matches.get_one::<String>("text") {
let mut body = serde_json::Map::new();
body.insert(data_field.to_string(), json!({"text": text}));
Ok(serde_json::Value::Object(body).to_string())
} else {
// Try to read from stdin, but since we can't easily test stdin in unit tests,
// we might check for TTY or empty stdin.
// For simplicity here, we assume if we reach here without text/json, we try stdin.
// Note: We removed the TTY check to avoid adding 'atty' or 'is-terminal' dependency.
// This means it will block on stdin if no input is provided, which is standard CLI behavior.
let stdin_text =
std::io::read_to_string(std::io::stdin()).context("Failed to read stdin")?;
if stdin_text.trim().is_empty() {
return Err(GwsError::Validation(
"Provide text via --text, --json, or pipe to stdin".to_string(),
));
}
let mut body = serde_json::Map::new();
body.insert(data_field.to_string(), json!({"text": stdin_text.trim()}));
Ok(serde_json::Value::Object(body).to_string())
}
}
#[cfg(test)]
mod parsing_tests {
use super::*;
use clap::{Arg, Command};
fn make_matches(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("json").long("json"))
.arg(Arg::new("text").long("text"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_sanitize_args_json() {
let matches = make_matches(&["test", "--json", "{\"foo\":\"bar\"}"]);
let body = parse_sanitize_args(&matches, "field").unwrap();
assert_eq!(body, "{\"foo\":\"bar\"}");
}
#[test]
fn test_parse_sanitize_args_text() {
let matches = make_matches(&["test", "--text", "hello"]);
let body = parse_sanitize_args(&matches, "field").unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["field"]["text"], "hello");
}
#[test]
fn test_build_create_template_url() {
let config = CreateTemplateConfig {
project: "p".to_string(),
location: "us-central1".to_string(),
template_id: "t".to_string(),
body: "{}".to_string(),
};
let url = build_create_template_url(&config);
// encode_path_segment encodes hyphens ('-' → '%2D')
assert_eq!(
url,
"https://modelarmor.us-central1.rep.googleapis.com/v1/projects/p/locations/us%2Dcentral1/templates?templateId=t"
);
}
fn make_matches_create(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("project").long("project").required(true))
.arg(Arg::new("location").long("location").required(true))
.arg(Arg::new("template-id").long("template-id").required(true))
.arg(Arg::new("json").long("json"))
.arg(Arg::new("preset").long("preset"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_create_template_args_json() {
let matches = make_matches_create(&[
"test",
"--project",
"p",
"--location",
"l",
"--template-id",
"t",
"--json",
"{\"a\":1}",
]);
let config = parse_create_template_args(&matches).unwrap();
assert_eq!(config.project, "p");
assert_eq!(config.location, "l");
assert_eq!(config.template_id, "t");
assert_eq!(config.body, "{\"a\":1}");
}
#[test]
fn test_parse_create_template_args_preset() {
let matches = make_matches_create(&[
"test",
"--project",
"p",
"--location",
"l",
"--template-id",
"t",
"--preset",
"jailbreak",
]);
let config = parse_create_template_args(&matches).unwrap();
assert_eq!(config.project, "p");
assert_eq!(config.location, "l");
assert_eq!(config.template_id, "t");
assert!(config.body.contains("piAndJailbreakFilterSettings"));
}
#[test]
fn test_load_preset_template_fallback() {
// Will test loading the built-in preset template
let content = load_preset_template("jailbreak").unwrap();
assert!(content.contains("piAndJailbreakFilterSettings"));
}
#[test]
fn test_inject_commands() {
let helper = ModelArmorHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+sanitize-prompt"));
assert!(subcommands.contains(&"+sanitize-response"));
assert!(subcommands.contains(&"+create-template"));
}
#[test]
fn test_build_create_template_url_encodes_segments() {
let config = CreateTemplateConfig {
project: "my-project".to_string(),
location: "us-central1".to_string(),
template_id: "my-template".to_string(),
body: "{}".to_string(),
};
let url = build_create_template_url(&config);
assert!(url.contains("projects/my%2Dproject"));
assert!(url.contains("locations/us%2Dcentral1"));
assert!(url.contains("templateId=my%2Dtemplate"));
}
#[test]
fn test_parse_create_template_args_rejects_traversal() {
let matches = make_matches_create(&[
"test",
"--project",
"../etc",
"--location",
"us-central1",
"--template-id",
"t",
"--preset",
"jailbreak",
]);
assert!(parse_create_template_args(&matches).is_err());
}
}
@@ -0,0 +1,290 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use anyhow::Context;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::fs;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub struct ScriptHelper;
impl Helper for ScriptHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+push")
.about("[Helper] Upload local files to an Apps Script project")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("dir")
.long("dir")
.help("Directory containing script files (defaults to current dir)")
.value_name("DIR"),
)
.after_help(
"\
EXAMPLES:
gws script +push --script SCRIPT_ID
gws script +push --script SCRIPT_ID --dir ./src
TIPS:
Supports .gs, .js, .html, and appsscript.json files.
Skips hidden files and node_modules automatically.
This replaces ALL files in the project.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+push") {
let script_id = matches.get_one::<String>("script").unwrap();
let dir_path = matches
.get_one::<String>("dir")
.map(|s| s.as_str())
.unwrap_or(".");
let safe_dir = crate::validate::validate_safe_dir_path(dir_path)?;
let mut files = Vec::new();
visit_dirs(&safe_dir, &mut files)?;
if files.is_empty() {
return Err(GwsError::Validation(format!(
"No eligible files found in '{}'",
dir_path
)));
}
// Find method: projects.updateContent
let projects_res = doc.resources.get("projects").ok_or_else(|| {
GwsError::Discovery("Resource 'projects' not found".to_string())
})?;
let update_method = projects_res.methods.get("updateContent").ok_or_else(|| {
GwsError::Discovery("Method 'projects.updateContent' not found".to_string())
})?;
// Build body
let body = json!({
"files": files
});
let body_str = body.to_string();
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Script auth failed: {e}"))),
};
let params = json!({
"scriptId": script_id
});
let params_str = params.to_string();
executor::execute_method(
doc,
update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn visit_dirs(dir: &Path, files: &mut Vec<serde_json::Value>) -> Result<(), GwsError> {
if dir.is_dir() {
for entry in fs::read_dir(dir).context("Failed to read dir")? {
let entry = entry.context("Failed to read entry")?;
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, files)?;
} else if let Some(file_obj) = process_file(&path)? {
files.push(file_obj);
}
}
}
Ok(())
}
fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
// Skip hidden files, node_modules, .git, etc. (basic filtering)
if filename.starts_with('.') || path.components().any(|c| c.as_os_str() == "node_modules") {
return Ok(None);
}
let (type_val, name_val) = match extension {
"gs" | "js" => (
"SERVER_JS",
filename.trim_end_matches(".js").trim_end_matches(".gs"),
),
"html" => ("HTML", filename.trim_end_matches(".html")),
"json" => {
if filename == "appsscript.json" {
("JSON", "appsscript")
} else {
return Ok(None);
}
}
_ => return Ok(None),
};
let content = fs::read_to_string(path).map_err(|e| {
GwsError::Validation(format!("Failed to read file '{}': {}", path.display(), e))
})?;
Ok(Some(json!({
"name": name_val,
"type": type_val,
"source": content
})))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_process_file_server_js() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("code.gs");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "function foo() {{}}").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "code");
assert_eq!(result["type"], "SERVER_JS");
assert_eq!(
result["source"].as_str().unwrap().trim(),
"function foo() {}"
);
}
#[test]
fn test_process_file_html() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("index.html");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "<html></html>").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "index");
assert_eq!(result["type"], "HTML");
}
#[test]
fn test_process_file_appsscript_json() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("appsscript.json");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "{{}}").unwrap();
let result = process_file(&file_path).unwrap().unwrap();
assert_eq!(result["name"], "appsscript");
assert_eq!(result["type"], "JSON");
}
#[test]
fn test_process_file_ignored() {
let dir = tempdir().unwrap();
// Random JSON
let p1 = dir.path().join("other.json");
File::create(&p1).unwrap();
assert!(process_file(&p1).unwrap().is_none());
// Hidden file
let p2 = dir.path().join(".hidden.gs");
File::create(&p2).unwrap();
assert!(process_file(&p2).unwrap().is_none());
// node_modules
let node_modules = dir.path().join("node_modules");
fs::create_dir(&node_modules).unwrap();
let p3 = node_modules.join("dep.gs");
File::create(&p3).unwrap();
assert!(process_file(&p3).unwrap().is_none());
}
#[test]
fn test_visit_dirs() {
let dir = tempdir().unwrap();
// Root file
let f1 = dir.path().join("root.gs");
File::create(&f1).unwrap();
// Subdir file
let sub = dir.path().join("src");
fs::create_dir(&sub).unwrap();
let f2 = sub.join("utils.js");
File::create(&f2).unwrap();
// Ignored file
let f3 = dir.path().join("ignore.txt");
File::create(&f3).unwrap();
let mut files = Vec::new();
visit_dirs(dir.path(), &mut files).unwrap();
assert_eq!(files.len(), 2);
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert!(names.contains(&"root"));
assert!(names.contains(&"utils"));
}
}
@@ -0,0 +1,525 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
pub struct SheetsHelper;
impl Helper for SheetsHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(
Command::new("+append")
.about("[Helper] Append a row to a spreadsheet")
.arg(
Arg::new("spreadsheet")
.long("spreadsheet")
.help("Spreadsheet ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("values")
.long("values")
.help("Comma-separated values (simple strings)")
.value_name("VALUES"),
)
.arg(
Arg::new("json-values")
.long("json-values")
.help("JSON array of rows, e.g. '[[\"a\",\"b\"],[\"c\",\"d\"]]'")
.value_name("JSON"),
)
.arg(
Arg::new("range")
.long("range")
.help("Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet)")
.value_name("RANGE"),
)
.after_help(
r#"EXAMPLES:
gws sheets +append --spreadsheet ID --values 'Alice,100,true'
gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]'
gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100'
TIPS:
Use --values for simple single-row appends.
Use --json-values for bulk multi-row inserts.
Use --range to target a specific sheet tab (default: A1, i.e. first sheet)."#,
),
);
cmd = cmd.subcommand(
Command::new("+read")
.about("[Helper] Read values from a spreadsheet")
.arg(
Arg::new("spreadsheet")
.long("spreadsheet")
.help("Spreadsheet ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("range")
.long("range")
.help("Range to read (e.g. 'Sheet1!A1:B2')")
.required(true)
.value_name("RANGE"),
)
.after_help(
"\
EXAMPLES:
gws sheets +read --spreadsheet ID --range \"Sheet1!A1:D10\"
gws sheets +read --spreadsheet ID --range Sheet1
TIPS:
Read-only — never modifies the spreadsheet.
For advanced options, use the raw values.get API.",
),
);
cmd
}
fn handle<'a>(
&'a self,
doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+append") {
let config = parse_append_args(matches);
let (params_str, body_str, scopes) = build_append_request(&config, doc)?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))),
};
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
})?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let append_method = values_res.methods.get("append").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
})?;
let pagination = executor::PaginationConfig {
page_all: false,
page_limit: 10,
page_delay_ms: 100,
};
executor::execute_method(
doc,
append_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
if let Some(matches) = matches.subcommand_matches("+read") {
let config = parse_read_args(matches);
let (params_str, scopes) = build_read_request(&config, doc)?;
// Re-find method
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
})?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let get_method = values_res.methods.get("get").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
})?;
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))),
};
executor::execute_method(
doc,
get_method,
Some(&params_str),
None,
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
}
Ok(false)
})
}
}
fn build_append_request(
config: &AppendConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, String, Vec<String>), GwsError> {
let spreadsheets_res = doc
.resources
.get("spreadsheets")
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let append_method = values_res.methods.get("append").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
})?;
let params = json!({
"spreadsheetId": config.spreadsheet_id,
"range": config.range,
"valueInputOption": "USER_ENTERED"
});
let body = json!({
"values": config.values
});
// Map `&String` scope URLs to owned `String`s for the return value
let scopes: Vec<String> = append_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), body.to_string(), scopes))
}
fn build_read_request(
config: &ReadConfig,
doc: &crate::discovery::RestDescription,
) -> Result<(String, Vec<String>), GwsError> {
// ... resource lookup omitted for brevity ...
let spreadsheets_res = doc
.resources
.get("spreadsheets")
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
})?;
let get_method = values_res.methods.get("get").ok_or_else(|| {
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
})?;
let params = json!({
"spreadsheetId": config.spreadsheet_id,
"range": config.range
});
let scopes: Vec<String> = get_method.scopes.iter().map(|s| s.to_string()).collect();
Ok((params.to_string(), scopes))
}
/// Configuration for appending values to a spreadsheet.
///
/// Holds the parsed arguments for the `+append` subcommand.
pub struct AppendConfig {
/// The ID of the spreadsheet to append to.
pub spreadsheet_id: String,
/// Target range in A1 notation (e.g. "Sheet2!A1"). Defaults to "A1".
pub range: String,
/// The rows to append, where each inner Vec represents one row.
pub values: Vec<Vec<String>>,
}
/// Parses arguments for the `+append` command.
///
/// Supports both `--values` (single row) and `--json-values` (single or multi-row).
pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig {
let values = if let Some(json_str) = matches.get_one::<String>("json-values") {
// Try parsing as array-of-arrays (multi-row) first
if let Ok(parsed) = serde_json::from_str::<Vec<Vec<String>>>(json_str) {
parsed
} else if let Ok(parsed) = serde_json::from_str::<Vec<String>>(json_str) {
// Single flat array — treat as one row
vec![parsed]
} else {
eprintln!(
"Warning: --json-values is not valid JSON; expected an array or array-of-arrays"
);
Vec::new()
}
} else if let Some(values_str) = matches.get_one::<String>("values") {
vec![values_str.split(',').map(|s| s.to_string()).collect()]
} else {
Vec::new()
};
let range = matches
.get_one::<String>("range")
.cloned()
.unwrap_or_else(|| "A1".to_string());
AppendConfig {
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
range,
values,
}
}
/// Configuration for reading values from a spreadsheet.
pub struct ReadConfig {
pub spreadsheet_id: String,
/// A1 notation range (e.g. "Sheet1!A1:B2").
pub range: String,
}
pub fn parse_read_args(matches: &ArgMatches) -> ReadConfig {
ReadConfig {
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
range: matches.get_one::<String>("range").unwrap().clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{RestDescription, RestMethod, RestResource};
use std::collections::HashMap;
fn make_mock_doc() -> RestDescription {
let mut methods = HashMap::new();
methods.insert(
"append".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
methods.insert(
"get".to_string(),
RestMethod {
scopes: vec!["https://scope".to_string()],
..Default::default()
},
);
let mut values_res = RestResource::default();
values_res.methods = methods;
let mut spreadsheets_res = RestResource::default();
spreadsheets_res
.resources
.insert("values".to_string(), values_res);
let mut resources = HashMap::new();
resources.insert("spreadsheets".to_string(), spreadsheets_res);
RestDescription {
resources,
..Default::default()
}
}
fn make_matches_append(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("spreadsheet").long("spreadsheet"))
.arg(Arg::new("values").long("values"))
.arg(Arg::new("json-values").long("json-values"))
.arg(Arg::new("range").long("range"));
cmd.try_get_matches_from(args).unwrap()
}
fn make_matches_read(args: &[&str]) -> ArgMatches {
let cmd = Command::new("test")
.arg(Arg::new("spreadsheet").long("spreadsheet"))
.arg(Arg::new("range").long("range"));
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_build_append_request() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "A1".to_string(),
values: vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]],
};
let (params, body, scopes) = build_append_request(&config, &doc).unwrap();
assert!(params.contains("123"));
assert!(params.contains("USER_ENTERED"));
assert!(params.contains("A1"));
assert!(body.contains("a"));
assert!(body.contains("b"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_build_append_request_with_range() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "Sheet2!A1".to_string(),
values: vec![vec!["x".to_string()]],
};
let (params, _body, _scopes) = build_append_request(&config, &doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&params).unwrap();
assert_eq!(parsed["range"], "Sheet2!A1");
}
#[test]
fn test_build_read_request() {
let doc = make_mock_doc();
let config = ReadConfig {
spreadsheet_id: "123".to_string(),
range: "A1:B2".to_string(),
};
let (params, scopes) = build_read_request(&config, &doc).unwrap();
assert!(params.contains("123"));
assert!(params.contains("A1:B2"));
assert_eq!(scopes[0], "https://scope");
}
#[test]
fn test_parse_append_args_values() {
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a,b,c"]);
let config = parse_append_args(&matches);
assert_eq!(config.spreadsheet_id, "123");
assert_eq!(config.range, "A1");
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
}
#[test]
fn test_parse_append_args_with_range() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--range",
"Sheet2!A1",
"--values",
"a,b",
]);
let config = parse_append_args(&matches);
assert_eq!(config.range, "Sheet2!A1");
}
#[test]
fn test_parse_append_args_default_range() {
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a"]);
let config = parse_append_args(&matches);
assert_eq!(config.range, "A1");
}
#[test]
fn test_parse_append_args_json_single_row() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--json-values",
r#"["a","b","c"]"#,
]);
let config = parse_append_args(&matches);
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
}
#[test]
fn test_parse_append_args_json_multi_row() {
let matches = make_matches_append(&[
"test",
"--spreadsheet",
"123",
"--json-values",
r#"[["Alice","100"],["Bob","200"]]"#,
]);
let config = parse_append_args(&matches);
assert_eq!(
config.values,
vec![vec!["Alice", "100"], vec!["Bob", "200"]]
);
}
#[test]
fn test_build_append_request_multi_row() {
let doc = make_mock_doc();
let config = AppendConfig {
spreadsheet_id: "123".to_string(),
range: "A1".to_string(),
values: vec![
vec!["Alice".to_string(), "100".to_string()],
vec!["Bob".to_string(), "200".to_string()],
],
};
let (_params, body, _scopes) = build_append_request(&config, &doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let values = parsed["values"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values[0], json!(["Alice", "100"]));
assert_eq!(values[1], json!(["Bob", "200"]));
}
#[test]
fn test_parse_read_args() {
let matches = make_matches_read(&["test", "--spreadsheet", "123", "--range", "A1:B2"]);
let config = parse_read_args(&matches);
assert_eq!(config.spreadsheet_id, "123");
assert_eq!(config.range, "A1:B2");
}
#[test]
fn test_inject_commands() {
let helper = SheetsHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(subcommands.contains(&"+append"));
assert!(subcommands.contains(&"+read"));
}
}
@@ -0,0 +1,777 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Cross-service workflow helpers that compose multiple Google Workspace API
//! calls into high-level productivity actions.
use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
use std::pin::Pin;
pub struct WorkflowHelper;
impl Helper for WorkflowHelper {
fn inject_commands(
&self,
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
cmd = cmd.subcommand(build_standup_report_cmd());
cmd = cmd.subcommand(build_meeting_prep_cmd());
cmd = cmd.subcommand(build_email_to_task_cmd());
cmd = cmd.subcommand(build_weekly_digest_cmd());
cmd = cmd.subcommand(build_file_announce_cmd());
cmd
}
fn handle<'a>(
&'a self,
_doc: &'a crate::discovery::RestDescription,
matches: &'a ArgMatches,
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(m) = matches.subcommand_matches("+standup-report") {
handle_standup_report(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+meeting-prep") {
handle_meeting_prep(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+email-to-task") {
handle_email_to_task(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+weekly-digest") {
handle_weekly_digest(m).await?;
return Ok(true);
}
if let Some(m) = matches.subcommand_matches("+file-announce") {
handle_file_announce(m).await?;
return Ok(true);
}
Ok(false)
})
}
fn helper_only(&self) -> bool {
true
}
}
// ---------------------------------------------------------------------------
// Command definitions
// ---------------------------------------------------------------------------
fn build_standup_report_cmd() -> Command {
Command::new("+standup-report")
.about("[Helper] Today's meetings + open tasks as a standup summary")
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +standup-report
gws workflow +standup-report --format table
TIPS:
Read-only — never modifies data.
Combines calendar agenda (today) with tasks list.",
)
}
fn build_meeting_prep_cmd() -> Command {
Command::new("+meeting-prep")
.about("[Helper] Prepare for your next meeting: agenda, attendees, and linked docs")
.arg(
Arg::new("calendar")
.long("calendar")
.help("Calendar ID (default: primary)")
.default_value("primary")
.value_name("ID"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +meeting-prep
gws workflow +meeting-prep --calendar Work
TIPS:
Read-only — never modifies data.
Shows the next upcoming event with attendees and description.",
)
}
fn build_email_to_task_cmd() -> Command {
Command::new("+email-to-task")
.about("[Helper] Convert a Gmail message into a Google Tasks entry")
.arg(
Arg::new("message-id")
.long("message-id")
.help("Gmail message ID to convert")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("tasklist")
.long("tasklist")
.help("Task list ID (default: @default)")
.default_value("@default")
.value_name("ID"),
)
.after_help(
"\
EXAMPLES:
gws workflow +email-to-task --message-id MSG_ID
gws workflow +email-to-task --message-id MSG_ID --tasklist LIST_ID
TIPS:
Reads the email subject as the task title and snippet as notes.
Creates a new task — confirm with the user before executing.",
)
}
fn build_weekly_digest_cmd() -> Command {
Command::new("+weekly-digest")
.about("[Helper] Weekly summary: this week's meetings + unread email count")
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +weekly-digest
gws workflow +weekly-digest --format table
TIPS:
Read-only — never modifies data.
Combines calendar agenda (week) with gmail triage summary.",
)
}
fn build_file_announce_cmd() -> Command {
Command::new("+file-announce")
.about("[Helper] Announce a Drive file in a Chat space")
.arg(
Arg::new("file-id")
.long("file-id")
.help("Drive file ID to announce")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("space")
.long("space")
.help("Chat space name (e.g. spaces/SPACE_ID)")
.required(true)
.value_name("SPACE"),
)
.arg(
Arg::new("message")
.long("message")
.help("Custom announcement message")
.value_name("TEXT"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format: json (default), table, yaml, csv")
.value_name("FORMAT")
.global(true),
)
.after_help(
"\
EXAMPLES:
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123 --message 'Check this out!'
TIPS:
This is a write command — sends a Chat message.
Use `gws drive +upload` first to upload the file, then announce it here.
Fetches the file name from Drive to build the announcement.",
)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn get_json(
client: &reqwest::Client,
url: &str,
token: &str,
query: &[(&str, &str)],
) -> Result<Value, GwsError> {
let resp = client
.get(url)
.query(query)
.bearer_auth(token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "workflow_request_failed".to_string(),
enable_url: None,
});
}
resp.json::<Value>()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}")))
}
fn format_and_print(value: &Value, matches: &ArgMatches) {
let fmt = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or_default();
println!("{}", crate::formatter::format_value(value, &fmt));
}
async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly";
let token = auth::get_token(&[cal_scope, tasks_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
// Resolve account timezone for day boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let today_end_tz = today_start_tz + chrono::Duration::days(1);
let time_min = today_start_tz.to_rfc3339();
let time_max = today_end_tz.to_rfc3339();
// Fetch today's events
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "25"),
],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch calendar events: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let meetings: Vec<Value> = events
.iter()
.map(|e| {
json!({
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"end": e.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
// Fetch open tasks
let tasks_json = get_json(
&client,
"https://tasks.googleapis.com/tasks/v1/lists/@default/tasks",
&token,
&[("showCompleted", "false"), ("maxResults", "20")],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch tasks: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let tasks = tasks_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let open_tasks: Vec<Value> = tasks
.iter()
.map(|t| {
json!({
"title": t.get("title").and_then(|v| v.as_str()).unwrap_or(""),
"due": t.get("due").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = json!({
"meetings": meetings,
"meetingCount": meetings.len(),
"tasks": open_tasks,
"taskCount": open_tasks.len(),
"date": now_in_tz.format("%Y-%m-%d").to_string(),
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let token = auth::get_token(&[cal_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let calendar_id = matches
.get_one::<String>("calendar")
.map(|s| s.as_str())
.unwrap_or("primary");
// Use account timezone for current time
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339();
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(calendar_id),
);
let events_json = get_json(
&client,
&events_url,
&token,
&[
("timeMin", now_rfc.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "1"),
],
)
.await?;
let items = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
if items.is_empty() {
let output = json!({ "message": "No upcoming meetings found." });
format_and_print(&output, matches);
return Ok(());
}
let event = &items[0];
let attendees = event
.get("attendees")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
let attendee_list: Vec<Value> = attendees
.iter()
.map(|a| {
json!({
"email": a.get("email").and_then(|v| v.as_str()).unwrap_or(""),
"responseStatus": a.get("responseStatus").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = json!({
"summary": event.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": event.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"end": event.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
"description": event.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"location": event.get("location").and_then(|v| v.as_str()).unwrap_or(""),
"hangoutLink": event.get("hangoutLink").and_then(|v| v.as_str()).unwrap_or(""),
"htmlLink": event.get("htmlLink").and_then(|v| v.as_str()).unwrap_or(""),
"attendees": attendee_list,
"attendeeCount": attendee_list.len(),
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
let tasks_scope = "https://www.googleapis.com/auth/tasks";
let token = auth::get_token(&[gmail_scope, tasks_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let message_id = matches.get_one::<String>("message-id").unwrap();
let tasklist = matches
.get_one::<String>("tasklist")
.map(|s| s.as_str())
.unwrap_or("@default");
// 1. Fetch the email
let msg_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
crate::validate::encode_path_segment(message_id),
);
let msg_json = get_json(
&client,
&msg_url,
&token,
&[("format", "metadata"), ("metadataHeaders", "Subject")],
)
.await?;
let subject = msg_json
.get("payload")
.and_then(|p| p.get("headers"))
.and_then(|h| h.as_array())
.and_then(|headers| {
headers.iter().find(|h| {
h.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Subject"))
})
})
.and_then(|h| h.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("(No subject)");
let snippet = msg_json
.get("snippet")
.and_then(|v| v.as_str())
.unwrap_or("");
// 2. Create the task
let task_body = json!({
"title": subject,
"notes": format!("From email: {}\n\n{}", message_id, snippet),
});
let tasklist = crate::validate::validate_resource_name(tasklist)?;
let task_url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks",
tasklist,
);
let resp = client
.post(&task_url)
.bearer_auth(&token)
.json(&task_body)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to create task: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "task_create_failed".to_string(),
enable_url: None,
});
}
let task_result: Value = resp.json().await.unwrap_or(json!({}));
let output = json!({
"created": true,
"taskId": task_result.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"title": subject,
"sourceMessageId": message_id,
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
let token = auth::get_token(&[cal_scope, gmail_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
// Resolve account timezone for week boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let week_end = now_in_tz + chrono::Duration::days(7);
let time_min = now_in_tz.to_rfc3339();
let time_max = week_end.to_rfc3339();
// Fetch this week's events
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch calendar events: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let meetings: Vec<Value> = events
.iter()
.map(|e| {
json!({
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
// Fetch unread email count
let gmail_json = get_json(
&client,
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
&token,
&[("q", "is:unread"), ("maxResults", "1")],
)
.await
.inspect_err(|e| {
eprintln!(
"Warning: Failed to fetch unread email count: {}",
sanitize_for_terminal(&e.to_string())
);
})
.unwrap_or(json!({}));
let unread_estimate = gmail_json
.get("resultSizeEstimate")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output = json!({
"meetings": meetings,
"meetingCount": meetings.len(),
"unreadEmails": unread_estimate,
"periodStart": time_min,
"periodEnd": time_max,
});
format_and_print(&output, matches);
Ok(())
}
async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
let drive_scope = "https://www.googleapis.com/auth/drive.readonly";
let chat_scope = "https://www.googleapis.com/auth/chat.messages.create";
let token = auth::get_token(&[drive_scope, chat_scope])
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
let client = crate::client::build_client()?;
let file_id = matches.get_one::<String>("file-id").unwrap();
let space = matches.get_one::<String>("space").unwrap();
let custom_msg = matches.get_one::<String>("message");
// 1. Fetch file metadata from Drive
let file_url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::validate::encode_path_segment(file_id),
);
let file_json = get_json(
&client,
&file_url,
&token,
&[("fields", "id,name,webViewLink")],
)
.await?;
let file_name = file_json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("file");
let default_link = format!("https://drive.google.com/file/d/{}/view", file_id);
let file_link = file_json
.get("webViewLink")
.and_then(|v| v.as_str())
.unwrap_or(&default_link);
// 2. Send Chat message
let msg_text = custom_msg
.map(|m| format!("{m}\n{file_link}"))
.unwrap_or_else(|| format!("📎 {file_name}\n{file_link}"));
let chat_body = json!({ "text": msg_text });
let space = crate::validate::validate_resource_name(space)?;
let chat_url = format!("https://chat.googleapis.com/v1/{}/messages", space);
let chat_resp = client
.post(&chat_url)
.bearer_auth(&token)
.json(&chat_body)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Chat send failed: {e}")))?;
if !chat_resp.status().is_success() {
let status = chat_resp.status();
let body = chat_resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "chat_send_failed".to_string(),
enable_url: None,
});
}
let output = json!({
"announced": true,
"fileName": file_name,
"fileLink": file_link,
"space": space,
});
format_and_print(&output, matches);
Ok(())
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
// (epoch_to_rfc3339 removed — replaced by account timezone resolution)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_commands() {
let helper = WorkflowHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let names: Vec<_> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
assert!(names.contains(&"+standup-report".to_string()));
assert!(names.contains(&"+meeting-prep".to_string()));
assert!(names.contains(&"+email-to-task".to_string()));
assert!(names.contains(&"+weekly-digest".to_string()));
assert!(names.contains(&"+file-announce".to_string()));
}
#[test]
fn test_helper_only() {
assert!(WorkflowHelper.helper_only());
}
// (test_epoch_to_rfc3339 removed — function replaced by timezone resolution)
#[test]
fn test_build_standup_report_cmd() {
let cmd = build_standup_report_cmd();
assert_eq!(cmd.get_name(), "+standup-report");
}
#[test]
fn test_build_meeting_prep_cmd() {
let cmd = build_meeting_prep_cmd();
assert_eq!(cmd.get_name(), "+meeting-prep");
}
#[test]
fn test_build_email_to_task_cmd() {
let cmd = build_email_to_task_cmd();
assert_eq!(cmd.get_name(), "+email-to-task");
// message-id is required
let args = cmd
.clone()
.try_get_matches_from(vec!["+email-to-task", "--message-id", "123"]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+email-to-task"]);
assert!(args_err.is_err());
}
#[test]
fn test_build_weekly_digest_cmd() {
let cmd = build_weekly_digest_cmd();
assert_eq!(cmd.get_name(), "+weekly-digest");
}
#[test]
fn test_build_file_announce_cmd() {
let cmd = build_file_announce_cmd();
assert_eq!(cmd.get_name(), "+file-announce");
let args = cmd.clone().try_get_matches_from(vec![
"+file-announce",
"--file-id",
"123",
"--space",
"spaces/test",
]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+file-announce"]);
assert!(args_err.is_err());
}
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Structured Logging
//!
//! Provides opt-in, PII-free logging for HTTP requests and CLI operations.
//! All output goes to stderr or a log file — stdout remains clean for
//! machine-consumable JSON output.
//!
//! ## Environment Variables
//!
//! - `GOOGLE_WORKSPACE_CLI_LOG`: Filter directive for stderr logging
//! (e.g., `gws=debug`, `gws=trace`). If unset, no stderr logging.
//!
//! - `GOOGLE_WORKSPACE_CLI_LOG_FILE`: Directory path for JSON-line log
//! files with daily rotation. If unset, no file logging.
use tracing_subscriber::prelude::*;
/// Environment variable controlling stderr log output.
const ENV_LOG: &str = "GOOGLE_WORKSPACE_CLI_LOG";
/// Environment variable controlling file log output.
const ENV_LOG_FILE: &str = "GOOGLE_WORKSPACE_CLI_LOG_FILE";
/// Initialize the tracing subscriber based on environment variables.
///
/// If neither `GOOGLE_WORKSPACE_CLI_LOG` nor `GOOGLE_WORKSPACE_CLI_LOG_FILE`
/// is set, this is a no-op and logging adds zero overhead.
///
/// This function must be called at most once (typically in `main()`).
/// Subsequent calls will silently fail (tracing only allows one global
/// subscriber).
pub fn init_logging() {
let stderr_filter = std::env::var(ENV_LOG).ok();
let log_file_dir = std::env::var(ENV_LOG_FILE).ok();
// If neither env var is set, skip initialization entirely for zero overhead.
if stderr_filter.is_none() && log_file_dir.is_none() {
return;
}
let registry = tracing_subscriber::registry();
// Stderr layer: human-readable, filtered by GOOGLE_WORKSPACE_CLI_LOG
let stderr_layer = stderr_filter.map(|filter| {
let env_filter = tracing_subscriber::EnvFilter::new(filter);
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(false)
.compact()
.with_filter(env_filter)
});
// File layer: JSON-line output with daily rotation
let (file_layer, _guard) = if let Some(ref dir) = log_file_dir {
let file_appender = tracing_appender::rolling::daily(dir, "gws.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.with_target(true)
.with_filter(tracing_subscriber::EnvFilter::new("gws=debug"));
(Some(layer), Some(guard))
} else {
(None, None)
};
// Compose layers and set as global subscriber.
// The guard is leaked intentionally so the non-blocking writer stays
// alive for the lifetime of the process.
let subscriber = registry.with(stderr_layer).with(file_layer);
if tracing::subscriber::set_global_default(subscriber).is_ok() {
if let Some(guard) = _guard {
// Leak the guard so the non-blocking writer lives for the process lifetime.
// This is the recommended pattern from tracing-appender docs.
std::mem::forget(guard);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_logging_default_no_panic() {
// With no env vars set, init_logging should be a no-op and not panic.
// We can't truly test the global subscriber in unit tests (it's global state),
// but we can verify the early-return path doesn't panic.
std::env::remove_var(ENV_LOG);
std::env::remove_var(ENV_LOG_FILE);
init_logging();
}
#[test]
fn test_env_var_names() {
assert_eq!(ENV_LOG, "GOOGLE_WORKSPACE_CLI_LOG");
assert_eq!(ENV_LOG_FILE, "GOOGLE_WORKSPACE_CLI_LOG_FILE");
}
}
+738
View File
@@ -0,0 +1,738 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Google Workspace CLI (gws)
//!
//! A dynamic, schema-driven CLI for Google Workspace APIs.
//! This tool dynamically parses Google API Discovery Documents to construct CLI commands.
//! It supports deep schema validation, OAuth / Service Account authentication,
//! interactive prompts, and integration with Model Armor.
mod auth;
pub(crate) mod auth_commands;
mod client;
mod commands;
pub(crate) mod credential_store;
mod discovery;
mod error;
mod executor;
mod formatter;
mod fs_util;
mod generate_skills;
mod helpers;
mod logging;
mod oauth_config;
mod output;
mod schema;
mod services;
mod setup;
mod setup_tui;
mod text;
mod timezone;
mod token_storage;
pub(crate) mod validate;
use error::{print_error_json, GwsError};
#[tokio::main]
async fn main() {
// Load .env file if present (silently ignored if missing)
let _ = dotenvy::dotenv();
// Initialize structured logging (no-op if env vars are unset)
logging::init_logging();
if let Err(err) = run().await {
print_error_json(&err);
std::process::exit(err.exit_code());
}
}
async fn run() -> Result<(), GwsError> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
return Err(GwsError::Validation(
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
));
}
// Find the first non-flag arg (skip --api-version and its value)
let mut first_arg: Option<String> = None;
{
let mut skip_next = false;
for a in args.iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if a == "--api-version" {
skip_next = true;
continue;
}
if a.starts_with("--api-version=") {
continue;
}
if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" {
first_arg = Some(a.clone());
break;
}
}
}
let first_arg = first_arg.ok_or_else(|| {
GwsError::Validation(
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
)
})?;
// Handle --help and --version at top level
if is_help_flag(&first_arg) {
print_usage();
return Ok(());
}
if is_version_flag(&first_arg) {
println!("gws {}", env!("CARGO_PKG_VERSION"));
println!("This is not an officially supported Google product.");
return Ok(());
}
// Handle the `schema` command
if first_arg == "schema" {
if args.len() < 3 {
return Err(GwsError::Validation(
"Usage: gws schema <service.resource.method> (e.g., gws schema drive.files.list) [--resolve-refs]"
.to_string(),
));
}
let resolve_refs = args.iter().any(|arg| arg == "--resolve-refs");
// Remove the flag if it exists so it doesn't mess up path parsing, or just pass the path
// The path is args[2], flags might follow.
let path = &args[2];
return schema::handle_schema_command(path, resolve_refs).await;
}
// Handle the `generate-skills` command
if first_arg == "generate-skills" {
let gen_args: Vec<String> = args.iter().skip(2).cloned().collect();
return generate_skills::handle_generate_skills(&gen_args).await;
}
// Handle the `auth` command
if first_arg == "auth" {
let auth_args: Vec<String> = args.iter().skip(2).cloned().collect();
return auth_commands::handle_auth_command(&auth_args).await;
}
// Parse service name and optional version override
let (api_name, version) = parse_service_and_version(&args, &first_arg)?;
// For synthetic services (no Discovery doc), use an empty RestDescription
let doc = if api_name == "workflow" {
discovery::RestDescription {
name: "workflow".to_string(),
description: Some("Cross-service productivity workflows".to_string()),
..Default::default()
}
} else {
// Fetch the Discovery Document
discovery::fetch_discovery_document(&api_name, &version)
.await
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?
};
// Build the dynamic command tree (all commands shown regardless of auth state)
let cli = commands::build_cli(&doc);
// Re-parse args (skip argv[0] which is the binary, and argv[1] which is the service name)
// Filter out --api-version and its value
// Prepend "gws" as the program name since try_get_matches_from expects argv[0]
let sub_args = filter_args_for_subcommand(&args, &first_arg);
let matches = cli.try_get_matches_from(&sub_args).map_err(|e| {
// If it's a help or version display, print it and exit cleanly
if e.kind() == clap::error::ErrorKind::DisplayHelp
|| e.kind() == clap::error::ErrorKind::DisplayVersion
{
print!("{e}");
std::process::exit(0);
}
GwsError::Validation(e.to_string())
})?;
// Resolve --format flag
let output_format = match matches.get_one::<String>("format") {
Some(s) => match formatter::OutputFormat::parse(s) {
Ok(fmt) => fmt,
Err(unknown) => {
eprintln!(
"warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)"
);
formatter::OutputFormat::Json
}
},
None => formatter::OutputFormat::default(),
};
// Resolve --sanitize template (flag or env var)
let sanitize_template = matches
.get_one::<String>("sanitize")
.cloned()
.or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE").ok());
let sanitize_mode = std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_MODE")
.map(|v| helpers::modelarmor::SanitizeMode::from_str(&v))
.unwrap_or(helpers::modelarmor::SanitizeMode::Warn);
let sanitize_config = parse_sanitize_config(sanitize_template, &sanitize_mode)?;
// Check if a helper wants to handle this command
if let Some(helper) = helpers::get_helper(&doc.name) {
if helper.handle(&doc, &matches, &sanitize_config).await? {
return Ok(());
}
}
// Walk the subcommand tree to find the target method
let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?;
let params_json = matched_args.get_one::<String>("params").map(|s| s.as_str());
let body_json = matched_args
.try_get_one::<String>("json")
.ok()
.flatten()
.map(|s| s.as_str());
let upload_path = matched_args
.try_get_one::<String>("upload")
.ok()
.flatten()
.map(|s| s.as_str());
let output_path = matched_args.get_one::<String>("output").map(|s| s.as_str());
// Validate file paths against traversal before any I/O.
// Use the returned canonical paths so the validated path is the one
// actually used for I/O (closes TOCTOU gap).
let upload_path_buf = if let Some(p) = upload_path {
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
} else {
None
};
let output_path_buf = if let Some(p) = output_path {
Some(crate::validate::validate_safe_file_path(p, "--output")?)
} else {
None
};
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
let upload = {
let upload_content_type = matched_args
.try_get_one::<String>("upload-content-type")
.ok()
.flatten()
.map(|s| s.as_str());
upload_path.map(|path| executor::UploadSource::File {
path,
content_type: upload_content_type,
})
};
let dry_run = matched_args.get_flag("dry-run");
// Build pagination config from flags
let pagination = parse_pagination_config(matched_args);
// Select the best scope for the method. Discovery Documents list scopes as
// alternatives (any one grants access). We pick the first (broadest) scope
// to avoid restrictive scopes like gmail.metadata that block query parameters.
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();
// Authenticate: try OAuth, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => {
// If credentials were found but failed (e.g. decryption error, invalid token),
// propagate the error instead of silently falling back to unauthenticated.
// Only fall back to None if no credentials exist at all.
let err_msg = format!("{e:#}");
// NB: matches the bail!() message in auth::load_credentials_inner
if err_msg.starts_with("No credentials found") {
(None, executor::AuthMethod::None)
} else {
return Err(GwsError::Auth(format!("Authentication failed: {err_msg}")));
}
}
};
// Execute
executor::execute_method(
&doc,
method,
params_json,
body_json,
token.as_deref(),
auth_method,
output_path,
upload,
dry_run,
&pagination,
sanitize_config.template.as_deref(),
&sanitize_config.mode,
&output_format,
false,
)
.await
.map(|_| ())
}
/// Select the best scope from a method's scope list.
///
/// Discovery Documents list method scopes as alternatives — any single scope
/// grants access. The first scope is typically the broadest. Using all scopes
/// causes issues when restrictive scopes (e.g., `gmail.metadata`) are included,
/// as the API enforces that scope's restrictions even when broader scopes are
/// also present.
pub(crate) fn select_scope(scopes: &[String]) -> Option<&str> {
scopes.first().map(|s| s.as_str())
}
fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig {
executor::PaginationConfig {
page_all: matches.get_flag("page-all"),
page_limit: matches.get_one::<u32>("page-limit").copied().unwrap_or(10),
page_delay_ms: matches.get_one::<u64>("page-delay").copied().unwrap_or(100),
}
}
pub fn parse_service_and_version(
args: &[String],
first_arg: &str,
) -> Result<(String, String), GwsError> {
let mut service_arg = first_arg;
let mut version_override: Option<String> = None;
// Check for --api-version flag anywhere in args
for i in 0..args.len() {
if args[i] == "--api-version" && i + 1 < args.len() {
version_override = Some(args[i + 1].clone());
}
}
// Support "service:version" syntax on the service arg itself
if let Some((svc, ver)) = service_arg.split_once(':') {
service_arg = svc;
if version_override.is_none() {
version_override = Some(ver.to_string());
}
}
let (api_name, default_version) = services::resolve_service(service_arg)?;
let version = version_override.unwrap_or(default_version);
Ok((api_name, version))
}
pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec<String> {
let mut sub_args: Vec<String> = vec!["gws".to_string()];
let mut skip_next = false;
let mut service_skipped = false;
for arg in args.iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--api-version" {
skip_next = true;
continue;
}
if arg.starts_with("--api-version=") {
continue;
}
if !service_skipped && arg == service_name {
service_skipped = true;
continue;
}
sub_args.push(arg.clone());
}
sub_args
}
fn parse_sanitize_config(
template: Option<String>,
mode: &helpers::modelarmor::SanitizeMode,
) -> Result<helpers::modelarmor::SanitizeConfig, GwsError> {
Ok(helpers::modelarmor::SanitizeConfig {
template,
mode: mode.clone(),
})
}
/// Recursively walks clap ArgMatches to find the leaf method and its matches.
fn resolve_method_from_matches<'a>(
doc: &'a discovery::RestDescription,
matches: &'a clap::ArgMatches,
) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> {
// Walk the subcommand chain
let mut path: Vec<&str> = Vec::new();
let mut current_matches = matches;
while let Some((sub_name, sub_matches)) = current_matches.subcommand() {
path.push(sub_name);
current_matches = sub_matches;
}
if path.is_empty() {
return Err(GwsError::Validation(
"No resource or method specified".to_string(),
));
}
// path looks like ["files", "list"] or ["files", "permissions", "list"]
// Walk the Discovery Document resources to find the method
let resource_name = path[0];
let resource = doc
.resources
.get(resource_name)
.ok_or_else(|| GwsError::Validation(format!("Resource '{resource_name}' not found")))?;
let mut current_resource = resource;
// Navigate sub-resources (everything except the last element, which is the method)
for &name in &path[1..path.len() - 1] {
// Check if this is a sub-resource
if let Some(sub) = current_resource.resources.get(name) {
current_resource = sub;
} else {
return Err(GwsError::Validation(format!(
"Sub-resource '{name}' not found"
)));
}
}
// The last element is the method name
let method_name = path[path.len() - 1];
// Check if this is a method on the current resource
if let Some(method) = current_resource.methods.get(method_name) {
return Ok((method, current_matches));
}
// Maybe it's a resource that has methods — need one more subcommand
Err(GwsError::Validation(format!(
"Method '{method_name}' not found on resource. Available methods: {:?}",
current_resource.methods.keys().collect::<Vec<_>>()
)))
}
fn print_usage() {
println!("gws — Google Workspace CLI");
println!();
println!("USAGE:");
println!(" gws <service> <resource> [sub-resource] <method> [flags]");
println!(" gws schema <service.resource.method> [--resolve-refs]");
println!();
println!("EXAMPLES:");
println!(" gws drive files list --params '{{\"pageSize\": 10}}'");
println!(" gws drive files get --params '{{\"fileId\": \"abc123\"}}'");
println!(" gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'");
println!(" gws gmail users messages list --params '{{\"userId\": \"me\"}}'");
println!(" gws schema drive.files.list");
println!();
println!("FLAGS:");
println!(" --params <JSON> URL/Query parameters as JSON");
println!(" --json <JSON> Request body as JSON (POST/PATCH/PUT)");
println!(" --upload <PATH> Local file to upload as media content (multipart)");
println!(" --upload-content-type <MIME> MIME type of the uploaded file (auto-detected from extension if omitted)");
println!(" --output <PATH> Output file path for binary responses");
println!(" --format <FMT> Output format: json (default), table, yaml, csv");
println!(" --api-version <VER> Override the API version (e.g., v2, v3)");
println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)");
println!(" --page-limit <N> Max pages to fetch with --page-all (default: 10)");
println!(" --page-delay <MS> Delay between pages in ms (default: 100)");
println!();
println!("SERVICES:");
for entry in services::SERVICES {
let name = entry.aliases[0];
let aliases = if entry.aliases.len() > 1 {
format!(" (also: {})", entry.aliases[1..].join(", "))
} else {
String::new()
};
println!(" {:<20} {}{}", name, entry.description, aliases);
}
println!();
println!("ENVIRONMENT:");
println!(" GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)");
println!(" GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file");
println!(" GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)");
println!(
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
);
println!(
" GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"
);
println!(
" GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND Keyring backend: keyring (default) or file"
);
println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template");
println!(
" GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block"
);
println!(
" GOOGLE_WORKSPACE_PROJECT_ID Override the GCP project ID for quota and billing"
);
println!(" GOOGLE_WORKSPACE_CLI_LOG Log level for stderr (e.g., gws=debug)");
println!(
" GOOGLE_WORKSPACE_CLI_LOG_FILE Directory for JSON log files (daily rotation)"
);
println!();
println!("EXIT CODES:");
for (code, description) in crate::error::EXIT_CODE_DOCUMENTATION {
println!(" {:<5}{}", code, description);
}
println!();
println!("COMMUNITY:");
println!(" Star the repo: https://github.com/googleworkspace/cli");
println!(" Report bugs / request features: https://github.com/googleworkspace/cli/issues");
println!(" Please search existing issues first; if one already exists, comment there.");
println!();
println!("DISCLAIMER:");
println!(" This is not an officially supported Google product.");
}
fn is_help_flag(arg: &str) -> bool {
matches!(arg, "--help" | "-h")
}
fn is_version_flag(arg: &str) -> bool {
matches!(arg, "--version" | "-V" | "version")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pagination_config_defaults() {
let matches = clap::Command::new("test")
.arg(
clap::Arg::new("page-all")
.long("page-all")
.action(clap::ArgAction::SetTrue),
)
.arg(
clap::Arg::new("page-limit")
.long("page-limit")
.value_parser(clap::value_parser!(u32)),
)
.arg(
clap::Arg::new("page-delay")
.long("page-delay")
.value_parser(clap::value_parser!(u64)),
)
.get_matches_from(vec!["test"]);
let config = parse_pagination_config(&matches);
assert_eq!(config.page_all, false);
assert_eq!(config.page_limit, 10);
assert_eq!(config.page_delay_ms, 100);
}
#[test]
fn test_parse_pagination_config_custom() {
let matches = clap::Command::new("test")
.arg(
clap::Arg::new("page-all")
.long("page-all")
.action(clap::ArgAction::SetTrue),
)
.arg(
clap::Arg::new("page-limit")
.long("page-limit")
.value_parser(clap::value_parser!(u32)),
)
.arg(
clap::Arg::new("page-delay")
.long("page-delay")
.value_parser(clap::value_parser!(u64)),
)
.get_matches_from(vec![
"test",
"--page-all",
"--page-limit",
"20",
"--page-delay",
"500",
]);
let config = parse_pagination_config(&matches);
assert_eq!(config.page_all, true);
assert_eq!(config.page_limit, 20);
assert_eq!(config.page_delay_ms, 500);
}
#[test]
fn test_parse_sanitize_config_valid() {
let config = parse_sanitize_config(
Some("tpl".to_string()),
&helpers::modelarmor::SanitizeMode::Warn,
)
.unwrap();
assert_eq!(config.template.as_deref(), Some("tpl"));
}
#[test]
fn test_parse_sanitize_config_no_template() {
let config =
parse_sanitize_config(None, &helpers::modelarmor::SanitizeMode::Block).unwrap();
assert!(config.template.is_none());
assert_eq!(config.mode, helpers::modelarmor::SanitizeMode::Block);
}
#[test]
fn test_is_version_flag() {
assert!(is_version_flag("--version"));
assert!(is_version_flag("-V"));
assert!(is_version_flag("version"));
assert!(!is_version_flag("--ver"));
assert!(!is_version_flag("v"));
assert!(!is_version_flag("drive"));
}
#[test]
fn test_is_help_flag() {
assert!(is_help_flag("--help"));
assert!(is_help_flag("-h"));
assert!(!is_help_flag("help"));
assert!(!is_help_flag("--h"));
}
#[test]
fn test_resolve_method_from_matches_basic() {
let mut resources = std::collections::HashMap::new();
let mut files_res = crate::discovery::RestResource::default();
files_res.methods.insert(
"list".to_string(),
crate::discovery::RestMethod {
id: Some("drive.files.list".to_string()),
http_method: "GET".to_string(),
..Default::default()
},
);
resources.insert("files".to_string(), files_res);
let doc = discovery::RestDescription {
name: "drive".to_string(),
resources,
..Default::default()
};
// Simulate CLI structure
let cmd = clap::Command::new("gws")
.subcommand(clap::Command::new("files").subcommand(clap::Command::new("list")));
let matches = cmd.get_matches_from(vec!["gws", "files", "list"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.list"));
}
#[test]
fn test_resolve_method_from_matches_nested() {
let mut resources = std::collections::HashMap::new();
let mut files_res = crate::discovery::RestResource::default();
let mut permissions_res = crate::discovery::RestResource::default();
permissions_res.methods.insert(
"get".to_string(),
crate::discovery::RestMethod {
id: Some("drive.files.permissions.get".to_string()),
..Default::default()
},
);
files_res
.resources
.insert("permissions".to_string(), permissions_res);
resources.insert("files".to_string(), files_res);
let doc = discovery::RestDescription {
name: "drive".to_string(),
resources,
..Default::default()
};
let cmd =
clap::Command::new("gws").subcommand(clap::Command::new("files").subcommand(
clap::Command::new("permissions").subcommand(clap::Command::new("get")),
));
let matches = cmd.get_matches_from(vec!["gws", "files", "permissions", "get"]);
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get"));
}
#[test]
fn test_filter_args_strips_api_version() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"--api-version".into(),
"v3".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args, "drive");
assert_eq!(filtered, vec!["gws", "files", "list"]);
}
#[test]
fn test_filter_args_no_special_flags() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"files".into(),
"list".into(),
"--format".into(),
"table".into(),
];
let filtered = filter_args_for_subcommand(&args, "drive");
assert_eq!(filtered, vec!["gws", "files", "list", "--format", "table"]);
}
#[test]
fn test_select_scope_picks_first() {
let scopes = vec![
"https://mail.google.com/".to_string(),
"https://www.googleapis.com/auth/gmail.metadata".to_string(),
"https://www.googleapis.com/auth/gmail.modify".to_string(),
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
];
assert_eq!(select_scope(&scopes), Some("https://mail.google.com/"));
}
#[test]
fn test_select_scope_single() {
let scopes = vec!["https://www.googleapis.com/auth/drive".to_string()];
assert_eq!(
select_scope(&scopes),
Some("https://www.googleapis.com/auth/drive")
);
}
#[test]
fn test_select_scope_empty() {
let scopes: Vec<String> = vec![];
assert_eq!(select_scope(&scopes), None);
}
}
@@ -0,0 +1,257 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Helpers for the OAuth client configuration file.
//!
//! Uses the standard Google Cloud Console "installed application" JSON format:
//! ```json
//! {
//! "installed": {
//! "client_id": "...apps.googleusercontent.com",
//! "project_id": "my-project",
//! "auth_uri": "https://accounts.google.com/o/oauth2/auth",
//! "token_uri": "https://oauth2.googleapis.com/token",
//! "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
//! "client_secret": "GOCSPX-...",
//! "redirect_uris": ["http://localhost"]
//! }
//! }
//! ```
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// The "installed" application config from Google Cloud Console.
#[derive(Debug, Serialize, Deserialize)]
pub struct InstalledConfig {
pub client_id: String,
pub client_secret: String,
pub project_id: String,
pub auth_uri: String,
pub token_uri: String,
#[serde(default)]
pub auth_provider_x509_cert_url: String,
#[serde(default)]
pub redirect_uris: Vec<String>,
}
/// Wrapper matching the Google Cloud Console download format.
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientSecretFile {
pub installed: InstalledConfig,
}
/// Returns the path for the client secret config file.
pub fn client_config_path() -> PathBuf {
crate::auth_commands::config_dir().join("client_secret.json")
}
/// Saves OAuth client configuration in the standard Google Cloud Console format.
pub fn save_client_config(
client_id: &str,
client_secret: &str,
project_id: &str,
) -> anyhow::Result<PathBuf> {
let config = ClientSecretFile {
installed: InstalledConfig {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
project_id: project_id.to_string(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(),
redirect_uris: vec!["http://localhost".to_string()],
},
};
let path = client_config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&config)?;
crate::fs_util::atomic_write(&path, json.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write client config: {e}"))?;
Ok(path)
}
/// Loads OAuth client configuration from the standard Google Cloud Console format.
pub fn load_client_config() -> anyhow::Result<InstalledConfig> {
let path = client_config_path();
let data = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
let file: ClientSecretFile = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("Invalid client_secret.json format: {e}"))?;
Ok(file.installed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("client_secret.json");
let config = ClientSecretFile {
installed: InstalledConfig {
client_id: "test-id.apps.googleusercontent.com".to_string(),
client_secret: "GOCSPX-test".to_string(),
project_id: "my-project".to_string(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs"
.to_string(),
redirect_uris: vec!["http://localhost".to_string()],
},
};
let json = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&path, &json).unwrap();
let data = std::fs::read_to_string(&path).unwrap();
let loaded: ClientSecretFile = serde_json::from_str(&data).unwrap();
assert_eq!(
loaded.installed.client_id,
"test-id.apps.googleusercontent.com"
);
assert_eq!(loaded.installed.client_secret, "GOCSPX-test");
assert_eq!(loaded.installed.project_id, "my-project");
}
#[test]
fn test_parse_google_console_format() {
// Real format from Google Cloud Console download
let json = r#"{
"installed": {
"client_id": "test-client-id.apps.googleusercontent.com",
"project_id": "test-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "test-client-secret",
"redirect_uris": ["http://localhost"]
}
}"#;
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
assert_eq!(config.installed.project_id, "test-project-id");
assert_eq!(config.installed.client_secret, "test-client-secret");
assert_eq!(config.installed.redirect_uris, vec!["http://localhost"]);
}
#[test]
fn test_parse_missing_optional_fields() {
// Minimal format — only required fields
let json = r#"{
"installed": {
"client_id": "test-id",
"project_id": "test-project",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"client_secret": "secret"
}
}"#;
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
assert_eq!(config.installed.client_id, "test-id");
assert!(config.installed.redirect_uris.is_empty());
assert!(config.installed.auth_provider_x509_cert_url.is_empty());
}
#[test]
fn test_parse_invalid_json_fails() {
let json = r#"{ "wrong_key": {} }"#;
let result = serde_json::from_str::<ClientSecretFile>(json);
assert!(result.is_err());
}
#[test]
fn test_parse_missing_client_id_fails() {
let json = r#"{
"installed": {
"project_id": "test",
"auth_uri": "https://example.com",
"token_uri": "https://example.com",
"client_secret": "secret"
}
}"#;
let result = serde_json::from_str::<ClientSecretFile>(json);
assert!(result.is_err());
}
// Helper to manage the env var safely and clean up automatically
struct EnvGuard {
key: String,
original_value: Option<String>,
}
impl EnvGuard {
fn new(key: &str, value: &str) -> Self {
let original_value = std::env::var(key).ok();
std::env::set_var(key, value);
Self {
key: key.to_string(),
original_value,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(val) = &self.original_value {
std::env::set_var(&self.key, val);
} else {
std::env::remove_var(&self.key);
}
}
}
#[test]
#[serial_test::serial]
fn test_load_client_config() {
let dir = tempfile::tempdir().unwrap();
let _env_guard = EnvGuard::new(
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
dir.path().to_str().unwrap(),
);
// Initially no config file exists
let result = load_client_config();
let err = result.unwrap_err();
assert!(err.to_string().contains("Cannot read"));
// Create a valid config file
save_client_config("test-id", "test-secret", "test-project").unwrap();
// Now loading should succeed
let config = load_client_config().unwrap();
assert_eq!(config.client_id, "test-id");
assert_eq!(config.client_secret, "test-secret");
assert_eq!(config.project_id, "test-project");
// Create an invalid config file
let path = client_config_path();
std::fs::write(&path, "invalid json").unwrap();
let result = load_client_config();
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("Invalid client_secret.json format"));
}
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Shared output helpers for terminal sanitization, coloring, and stderr
//! messaging.
//!
//! Every function that prints untrusted content to the terminal should use
//! these helpers to prevent escape-sequence injection, Unicode spoofing,
//! and to respect `NO_COLOR` / non-TTY environments.
// Import dangerous-char detection from the library crate.
pub(crate) use google_workspace::validate::is_dangerous_unicode;
// ── Sanitization ──────────────────────────────────────────────────────
/// Strip dangerous characters from untrusted text before printing to the
/// terminal. Removes ASCII control characters (except `\n` and `\t`,
/// which are preserved for readability) and dangerous Unicode characters
/// (bidi overrides, zero-width chars, line/paragraph separators).
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
text.chars()
.filter(|&c| {
if c == '\n' || c == '\t' {
return true;
}
if c.is_control() {
return false;
}
!is_dangerous_unicode(c)
})
.collect()
}
// ── Color ─────────────────────────────────────────────────────────────
/// Returns true when stderr is connected to an interactive terminal and
/// `NO_COLOR` is not set, meaning ANSI color codes will be visible.
pub(crate) fn stderr_supports_color() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR`
/// is set.
pub(crate) fn colorize(text: &str, ansi_color: &str) -> String {
if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) {
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
} else {
text.to_string()
}
}
// ── Stderr helpers ────────────────────────────────────────────────────
/// Print a status message to stderr. The message is sanitized before
/// printing to prevent terminal injection.
#[allow(dead_code)]
pub(crate) fn status(msg: &str) {
eprintln!("{}", sanitize_for_terminal(msg));
}
/// Print a warning to stderr with a colored prefix. The message is
/// sanitized before printing.
#[allow(dead_code)]
pub(crate) fn warn(msg: &str) {
let prefix = colorize("warning:", "33"); // yellow
eprintln!("{prefix} {}", sanitize_for_terminal(msg));
}
/// Print an informational message to stderr. The message is sanitized
/// before printing.
#[allow(dead_code)]
pub(crate) fn info(msg: &str) {
eprintln!("{}", sanitize_for_terminal(msg));
}
#[cfg(test)]
mod tests {
use super::*;
// ── sanitize_for_terminal ─────────────────────────────────────
#[test]
fn sanitize_strips_ansi_escape_sequences() {
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
}
#[test]
fn sanitize_preserves_newlines_and_tabs() {
let input = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab");
}
#[test]
fn sanitize_strips_bell_and_backspace() {
let input = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input), "hellobellbackspace");
}
#[test]
fn sanitize_strips_carriage_return() {
let input = "real\rfake";
assert_eq!(sanitize_for_terminal(input), "realfake");
}
#[test]
fn sanitize_strips_bidi_overrides() {
let input = "hello\u{202E}dlrow";
assert_eq!(sanitize_for_terminal(input), "hellodlrow");
}
#[test]
fn sanitize_strips_zero_width_chars() {
assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar");
assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar");
}
#[test]
fn sanitize_strips_line_separators() {
assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2");
assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2");
}
#[test]
fn sanitize_strips_directional_isolates() {
assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc");
}
#[test]
fn sanitize_preserves_normal_unicode() {
assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ");
}
// ── colorize ──────────────────────────────────────────────────
#[test]
fn colorize_returns_text_in_no_color_mode() {
let result = colorize("hello", "31");
assert!(result.contains("hello"));
}
}
+468
View File
@@ -0,0 +1,468 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! JSON Schema Validation & Reference Resolution
//!
//! Provides utilities to validate JSON payloads against the Google API Discovery Document
//! schemas before dispatching requests. This ensures immediate client-side feedback
//! for invalid API payloads.
use serde_json::{json, Value};
use crate::discovery::{
fetch_discovery_document, JsonSchema, MethodParameter, RestDescription, RestMethod,
RestResource,
};
use crate::error::GwsError;
use crate::services::resolve_service;
/// Handles the `gws schema <dotted.path>` command.
///
/// Path format: `service.resource[.subresource].method`
/// Example: `drive.files.list` or `drive.files.permissions.list`
pub async fn handle_schema_command(path: &str, resolve_refs: bool) -> Result<(), GwsError> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() < 2 {
return Err(GwsError::Validation(format!(
"Schema path must be at least 'service.Message' or 'service.resource.method', got '{path}'"
)));
}
let service_name = parts[0];
let (api_name, version) = resolve_service(service_name)?;
let doc = fetch_discovery_document(&api_name, &version)
.await
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?;
// Case 1: Schema lookup (e.g., "drive.File")
if parts.len() == 2 {
let schema_name = parts[1];
if let Some(schema) = doc.schemas.get(schema_name) {
let mut output = schema_to_json(schema);
if resolve_refs {
let mut seen = std::collections::HashSet::new();
// Add self to seen to prevent immediate recursion
seen.insert(schema_name.to_string());
resolve_schema_refs(&mut output, &doc, &mut seen);
}
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
return Ok(());
} else {
// It might be a resource path that is incomplete, but let's see if it's a schema typo first
// or perhaps the user meant "drive.files" (resource) which we don't support dumping yet.
// Let's check if it matches a resource name to give a better error.
if doc.resources.contains_key(schema_name) {
return Err(GwsError::Validation(format!(
"'{schema_name}' is a resource. To see its methods, try 'gws schema {service_name}.{schema_name}.list' (or similar). To see a type definition, try 'gws schema {service_name}.<Type>'."
)));
}
let available: Vec<&String> = doc.schemas.keys().collect();
return Err(GwsError::Validation(format!(
"Schema or resource '{schema_name}' not found. Available schemas: {:?}",
available
)));
}
}
// Case 2: Method lookup (e.g., "drive.files.list")
let resource_path = &parts[1..parts.len() - 1];
let method_name = parts[parts.len() - 1];
let method = find_method(&doc, resource_path, method_name)?;
let mut output = build_schema_output(&doc, method);
if resolve_refs {
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut output, &doc, &mut seen);
}
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
Ok(())
}
/// Walks the resource tree to find a method.
fn find_method<'a>(
doc: &'a RestDescription,
resource_path: &[&str],
method_name: &str,
) -> Result<&'a RestMethod, GwsError> {
if resource_path.is_empty() {
return Err(GwsError::Validation(
"Resource path cannot be empty".to_string(),
));
}
let first_resource_name = resource_path[0];
let resource = doc.resources.get(first_resource_name).ok_or_else(|| {
let available: Vec<&String> = doc.resources.keys().collect();
GwsError::Validation(format!(
"Resource '{}' not found. Available resources: {:?}",
first_resource_name, available
))
})?;
// Walk deeper into sub-resources
let mut current_resource: &RestResource = resource;
for &sub_name in &resource_path[1..] {
current_resource = current_resource.resources.get(sub_name).ok_or_else(|| {
let available: Vec<&String> = current_resource.resources.keys().collect();
GwsError::Validation(format!(
"Sub-resource '{}' not found. Available: {:?}",
sub_name, available
))
})?;
}
current_resource.methods.get(method_name).ok_or_else(|| {
let available: Vec<&String> = current_resource.methods.keys().collect();
GwsError::Validation(format!(
"Method '{}' not found. Available methods: {:?}",
method_name, available
))
})
}
/// Builds the schema output JSON for a method.
fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value {
let mut params = json!({});
for (name, param) in &method.parameters {
params[name] = param_to_json(param);
}
let mut output = json!({
"httpMethod": method.http_method,
"path": method.path,
"description": method.description.as_deref().unwrap_or(""),
"parameters": params,
"scopes": method.scopes,
});
if !method.parameter_order.is_empty() {
output["parameterOrder"] = json!(method.parameter_order);
}
// Resolve request body schema
if let Some(ref req_ref) = method.request {
if let Some(ref schema_name) = req_ref.schema_ref {
output["requestBody"] = json!({
"schemaRef": schema_name,
});
if let Some(schema) = doc.schemas.get(schema_name) {
output["requestBody"]["schema"] = schema_to_json(schema);
}
}
}
// Response schema ref
if let Some(ref resp_ref) = method.response {
if let Some(ref schema_name) = resp_ref.schema_ref {
output["response"] = json!({
"schemaRef": schema_name,
});
// Also inline the response schema structure if available
if let Some(schema) = doc.schemas.get(schema_name) {
output["response"]["schema"] = schema_to_json(schema);
}
}
}
output
}
fn param_to_json(param: &MethodParameter) -> Value {
let mut p = json!({
"type": param.param_type.as_deref().unwrap_or("string"),
"required": param.required,
});
if let Some(ref loc) = param.location {
p["location"] = json!(loc);
}
if let Some(ref desc) = param.description {
p["description"] = json!(desc);
}
if let Some(ref fmt) = param.format {
p["format"] = json!(fmt);
}
if let Some(ref def) = param.default {
p["default"] = json!(def);
}
if let Some(ref vals) = param.enum_values {
p["enum"] = json!(vals);
}
if param.repeated {
p["repeated"] = json!(true);
}
if param.deprecated {
p["deprecated"] = json!(true);
}
p
}
fn schema_to_json(schema: &JsonSchema) -> Value {
let mut s = json!({});
if let Some(ref t) = schema.schema_type {
s["type"] = json!(t);
}
if let Some(ref desc) = schema.description {
s["description"] = json!(desc);
}
if !schema.properties.is_empty() {
let mut props = json!({});
for (name, prop) in &schema.properties {
let mut p = json!({});
if let Some(ref t) = prop.prop_type {
p["type"] = json!(t);
}
if let Some(ref r) = prop.schema_ref {
p["$ref"] = json!(r);
}
if let Some(ref desc) = prop.description {
p["description"] = json!(desc);
}
if prop.read_only {
p["readOnly"] = json!(true);
}
if let Some(ref fmt) = prop.format {
p["format"] = json!(fmt);
}
// Handle items for array types
if let Some(ref items) = prop.items {
let mut items_json = json!({});
if let Some(ref t) = items.prop_type {
items_json["type"] = json!(t);
}
if let Some(ref r) = items.schema_ref {
items_json["$ref"] = json!(r);
}
p["items"] = items_json;
}
props[name] = p;
}
s["properties"] = props;
}
s
}
/// Recursively resolves "$ref" fields in the JSON value.
fn resolve_schema_refs(
val: &mut Value,
doc: &RestDescription,
seen: &mut std::collections::HashSet<String>,
) {
match val {
Value::Object(map) => {
// Check if this object is a reference
if let Some(ref_name) = map
.get("$ref")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
{
// If we haven't seen this schema yet in this branch
if !seen.contains(&ref_name) {
if let Some(schema) = doc.schemas.get(&ref_name) {
seen.insert(ref_name.clone());
let mut resolved = schema_to_json(schema);
// Recursively resolve the resolved schema
resolve_schema_refs(&mut resolved, doc, seen);
seen.remove(&ref_name);
// Merge resolved schema into current object, but preserve existing fields
// (though usually $ref stands alone)
if let Value::Object(resolved_map) = resolved {
for (k, v) in resolved_map {
map.entry(k).or_insert(v);
}
}
}
}
}
// Recurse into all fields
for (_, v) in map.iter_mut() {
resolve_schema_refs(v, doc, seen);
}
}
Value::Array(arr) => {
for v in arr {
resolve_schema_refs(v, doc, seen);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_param_to_json() {
let param = MethodParameter {
param_type: Some("integer".to_string()),
description: Some("desc".to_string()),
location: Some("query".to_string()),
required: true,
format: Some("int32".to_string()),
default: Some("0".to_string()),
enum_values: Some(vec!["0".to_string(), "1".to_string()]),
enum_descriptions: None,
repeated: false,
minimum: None,
maximum: None,
deprecated: true,
};
let json = param_to_json(&param);
assert_eq!(json["type"], "integer");
assert_eq!(json["description"], "desc");
assert_eq!(json["location"], "query");
assert_eq!(json["required"], true);
assert_eq!(json["format"], "int32");
assert_eq!(json["default"], "0");
assert!(json["enum"].is_array());
assert_eq!(json["deprecated"], true);
// repeated: false should NOT appear in output
assert!(json.get("repeated").is_none());
}
#[test]
fn test_param_to_json_repeated() {
let param = MethodParameter {
param_type: Some("string".to_string()),
location: Some("query".to_string()),
repeated: true,
..Default::default()
};
let json = param_to_json(&param);
assert_eq!(json["type"], "string");
assert_eq!(json["repeated"], true);
}
#[test]
fn test_schema_to_json_basic() {
let mut properties = std::collections::HashMap::new();
properties.insert(
"name".to_string(),
crate::discovery::JsonSchemaProperty {
prop_type: Some("string".to_string()),
..Default::default()
},
);
let schema = JsonSchema {
schema_type: Some("object".to_string()),
properties,
..Default::default()
};
let json = schema_to_json(&schema);
assert_eq!(json["type"], "object");
assert!(json["properties"].is_object());
assert_eq!(json["properties"]["name"]["type"], "string");
}
#[test]
fn test_resolve_schema_refs_basic() {
let mut schemas = std::collections::HashMap::new();
let target_schema = JsonSchema {
schema_type: Some("string".to_string()),
description: Some("Resolved type".to_string()),
..Default::default()
};
schemas.insert("Target".to_string(), target_schema);
let doc = RestDescription {
schemas,
..Default::default()
};
let mut val = json!({
"$ref": "Target"
});
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut val, &doc, &mut seen);
assert_eq!(val["type"], "string");
assert_eq!(val["description"], "Resolved type");
// $ref might remain or effectively be merged, checking properties is key
}
#[test]
fn test_resolve_schema_refs_nested() {
let mut schemas = std::collections::HashMap::new();
let child = JsonSchema {
schema_type: Some("integer".to_string()),
..Default::default()
};
schemas.insert("Child".to_string(), child);
let parent = JsonSchema {
schema_type: Some("object".to_string()),
properties: {
let mut map = std::collections::HashMap::new();
map.insert(
"f".to_string(),
crate::discovery::JsonSchemaProperty {
schema_ref: Some("Child".to_string()),
..Default::default()
},
);
map
},
..Default::default()
};
schemas.insert("Parent".to_string(), parent);
let doc = RestDescription {
schemas,
..Default::default()
};
let mut val = json!({
"$ref": "Parent"
});
let mut seen = std::collections::HashSet::new();
resolve_schema_refs(&mut val, &doc, &mut seen);
// Check Parent resolved
assert_eq!(val["type"], "object");
// Check Child resolved inside Parent
// note: schema_to_json converts ref to $ref property, then resolve_schema_refs follows it
// The implementation matches on "$ref" keys in objects.
// schema_to_json for Parent produces { properties: { f: { $ref: "Child" } } }
// The recursion should resolve f.$ref to Child content.
let f_node = &val["properties"]["f"];
assert_eq!(f_node["type"], "integer");
}
}
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Service registry — re-exports from `google_workspace` library crate.
pub use google_workspace::services::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// Max chars for CLI `--help` method descriptions (terminal-width friendly).
pub const CLI_DESCRIPTION_LIMIT: usize = 200;
/// Max chars for YAML frontmatter / skill index descriptions (compact metadata).
pub const FRONTMATTER_DESCRIPTION_LIMIT: usize = 120;
/// Max chars for skill body method descriptions (markdown, agents benefit from detail).
pub const SKILL_BODY_DESCRIPTION_LIMIT: usize = 500;
/// Truncates a description string to `max_chars` using smart boundaries.
///
/// When `strip_links` is true, markdown links `[text](url)` are replaced with
/// just `text` to reclaim character budget (useful for CLI help / frontmatter).
/// When false, links are preserved (useful for skill body text where agents can
/// follow URLs).
///
/// Truncation strategy:
/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there.
/// 2. Otherwise, break at the last word boundary (space) and append `…`.
/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`.
pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String {
if max_chars == 0 {
return String::new();
}
let cleaned = if strip_links {
strip_markdown_links(desc)
} else {
desc.to_string()
};
let trimmed = cleaned.trim();
// Count chars (UTF-8 safe)
let char_count = trimmed.chars().count();
if char_count <= max_chars {
return trimmed.to_string();
}
// Collect the first `max_chars` characters as a string to search within.
let prefix: String = trimmed.chars().take(max_chars).collect();
// Try to find the last complete sentence within the limit.
// A sentence ends with ". " followed by more text, or "." at the end of
// the prefix. We look for the last ". " to find a sentence boundary.
if let Some(sentence_end) = find_last_sentence_boundary(&prefix) {
let truncated: String = trimmed.chars().take(sentence_end).collect();
return truncated;
}
// Fall back to last word boundary (space) within the limit.
if let Some(last_space) = rfind_char_boundary(&prefix, ' ') {
let truncated: String = trimmed.chars().take(last_space).collect();
return format!("{truncated}");
}
// Hard cut — no spaces at all
let truncated: String = trimmed.chars().take(max_chars - 1).collect();
format!("{truncated}")
}
/// Strips markdown-style links `[text](url)` and replaces them with just `text`.
fn strip_markdown_links(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '[' {
// Look for the closing ] followed by (
if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) {
if close_bracket + 1 < len && chars[close_bracket + 1] == '(' {
if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) {
// Found a complete [text](url) — emit just the text
result.extend(&chars[i + 1..close_bracket]);
i = close_paren + 1;
continue;
}
}
}
}
result.push(chars[i]);
i += 1;
}
result
}
/// Finds the character-index of `target` starting from position `from`.
fn find_char_from(chars: &[char], target: char, from: usize) -> Option<usize> {
chars[from..]
.iter()
.position(|&c| c == target)
.map(|p| from + p)
}
/// Finds the last sentence boundary within a char-indexed string.
/// A sentence boundary is a position right after ". " where we can cleanly cut.
/// Returns the char-count to include (up to and including the period).
fn find_last_sentence_boundary(prefix: &str) -> Option<usize> {
let chars: Vec<char> = prefix.chars().collect();
let mut last_boundary = None;
for (i, _) in chars.iter().enumerate() {
if chars[i] == '.' {
let after_period = i + 1;
// Sentence boundary: period followed by a space, or period at end of prefix
if after_period == chars.len()
|| (after_period < chars.len() && chars[after_period] == ' ')
{
last_boundary = Some(after_period);
}
}
}
last_boundary
}
/// Finds the last occurrence of `target` in a string, returning its char-index.
fn rfind_char_boundary(s: &str, target: char) -> Option<usize> {
let chars: Vec<char> = s.chars().collect();
chars.iter().rposition(|&c| c == target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_desc_unchanged() {
let desc = "Lists all files.";
assert_eq!(truncate_description(desc, 200, true), "Lists all files.");
}
#[test]
fn truncate_at_sentence_boundary() {
let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it.";
// At limit 30, only the first sentence fits before the sentence boundary.
let result = truncate_description(desc, 30, true);
assert_eq!(result, "Creates a file in Drive.");
// At limit 70, both first and second sentences fit.
let result = truncate_description(desc, 70, true);
assert_eq!(
result,
"Creates a file in Drive. This method supports multipart upload."
);
}
#[test]
fn truncate_at_word_boundary() {
let desc = "Create a guest user with access to a subset of Workspace capabilities";
let result = truncate_description(desc, 50, true);
// Should cut at the last space before char 50
assert!(result.ends_with('…'));
assert!(result.len() <= 55); // 50 chars + ellipsis
assert!(!result.contains("capabil")); // Should not cut mid-word
}
#[test]
fn hard_cut_no_spaces() {
let desc = "abcdefghijklmnopqrstuvwxyz";
let result = truncate_description(desc, 10, true);
assert_eq!(result, "abcdefghi…");
}
#[test]
fn strips_markdown_links() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
let result = truncate_description(desc, 200, true);
assert_eq!(
result,
"Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha."
);
assert!(!result.contains("https://"));
assert!(!result.contains('['));
}
#[test]
fn preserves_links_when_strip_links_false() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
let result = truncate_description(desc, 500, false);
assert!(result.contains("https://support.google.com"));
assert!(result.contains("[subset of Workspace capabilities]"));
}
#[test]
fn strips_markdown_links_and_truncates() {
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature.";
let result = truncate_description(desc, 120, true);
// After stripping the link, the sentence boundary should work.
assert!(result.contains("subset of Workspace capabilities."));
assert!(!result.contains("https://"));
}
#[test]
fn multibyte_safe() {
let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars";
let result = truncate_description(desc, 30, true);
assert!(result.ends_with('…') || result.chars().count() <= 30);
}
#[test]
fn empty_and_whitespace() {
assert_eq!(truncate_description("", 100, true), "");
assert_eq!(truncate_description(" ", 100, true), "");
assert_eq!(truncate_description("", 0, true), "");
}
#[test]
fn test_strip_markdown_links() {
assert_eq!(strip_markdown_links("[text](http://example.com)"), "text");
assert_eq!(
strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"),
"Use this link and that too"
);
assert_eq!(strip_markdown_links("no links here"), "no links here");
// Incomplete link syntax should be left alone
assert_eq!(strip_markdown_links("[broken"), "[broken");
assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens");
}
#[test]
fn preserves_sentence_ending_at_limit() {
let desc = "Deletes a user.";
assert_eq!(truncate_description(desc, 15, true), "Deletes a user.");
}
#[test]
fn does_not_cut_url_looking_periods() {
// Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends
// unless followed by a space
let desc = "See the docs at developers.google.com for more details on this API endpoint";
let result = truncate_description(desc, 50, true);
// Should truncate at word boundary, not at "developers."
assert!(result.ends_with('…'));
}
#[test]
fn sentence_boundary_at_exact_limit() {
// Period falls exactly at the end of the prefix — should still detect it
let desc = "This is a complete sentence. And more text follows here.";
let result = truncate_description(desc, 28, true);
assert_eq!(result, "This is a complete sentence.");
}
#[test]
fn zero_max_chars() {
assert_eq!(truncate_description("anything", 0, true), "");
}
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Account timezone resolution for Google Workspace CLI.
//!
//! Resolves the authenticated user's timezone with the following priority:
//! 1. Explicit `--timezone` CLI flag (hard error if invalid)
//! 2. Cached value from config dir (24h TTL)
//! 3. Google Calendar Settings API (`users/me/settings/timezone`)
//! 4. Machine-local timezone (fallback with warning)
use crate::error::GwsError;
use chrono_tz::Tz;
use std::path::PathBuf;
/// Cache filename stored in the gws config directory.
const CACHE_FILENAME: &str = "account_timezone";
/// Cache TTL in seconds (24 hours).
const CACHE_TTL_SECS: u64 = 86400;
/// Returns the path to the timezone cache file.
fn cache_path() -> PathBuf {
crate::auth_commands::config_dir().join(CACHE_FILENAME)
}
/// Remove the cached timezone file. Called on auth login/logout to
/// invalidate stale values when the account changes.
pub fn invalidate_cache() {
let path = cache_path();
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache");
}
}
}
/// Read the cached timezone if it exists and is fresh (< 24h old).
fn read_cache() -> Option<Tz> {
let path = cache_path();
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let age = std::time::SystemTime::now().duration_since(modified).ok()?;
if age.as_secs() > CACHE_TTL_SECS {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
let tz_name = contents.trim();
tz_name.parse::<Tz>().ok()
}
/// Write a timezone name to the cache file.
fn write_cache(tz_name: &str) {
let path = cache_path();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory");
return;
}
}
if let Err(e) = std::fs::write(&path, tz_name) {
tracing::warn!(path = %path.display(), error = %e, "failed to write timezone cache");
}
}
/// Fetch the account timezone from the Google Calendar Settings API.
async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result<Tz, GwsError> {
let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone";
let resp = client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch account timezone: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "timezone_fetch_failed".to_string(),
enable_url: None,
});
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse timezone response: {e}")))?;
let tz_name = json
.get("value")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
GwsError::Other(anyhow::anyhow!(
"Timezone setting missing or empty 'value' field"
))
})?;
let tz: Tz = tz_name.parse().map_err(|_| {
GwsError::Other(anyhow::anyhow!(
"Google returned unrecognized timezone: {tz_name}"
))
})?;
// Cache for future use
write_cache(tz_name);
tracing::info!(
timezone = tz_name,
source = "calendar_api",
"resolved account timezone"
);
Ok(tz)
}
/// Parse an explicit timezone string, returning an error if invalid.
pub fn parse_timezone(tz_str: &str) -> Result<Tz, GwsError> {
tz_str.parse::<Tz>().map_err(|_| {
GwsError::Validation(format!(
"Invalid timezone '{tz_str}'. Use an IANA timezone name (e.g. America/Denver, Europe/London, UTC)."
))
})
}
/// Resolve the user's timezone with this priority:
/// 1. `tz_override` (from `--timezone` flag) — hard error if invalid
/// 2. Cached value in config dir — use if < 24h old
/// 3. Google Calendar Settings API — fetch and cache
/// 4. Machine-local timezone (log warning)
pub async fn resolve_account_timezone(
client: &reqwest::Client,
token: &str,
tz_override: Option<&str>,
) -> Result<Tz, GwsError> {
// 1. Explicit override — fail if invalid
if let Some(tz_str) = tz_override {
let tz = parse_timezone(tz_str)?;
tracing::info!(
timezone = tz_str,
source = "cli_flag",
"using explicit timezone"
);
return Ok(tz);
}
// 2. Check cache
if let Some(tz) = read_cache() {
tracing::debug!(timezone = %tz, source = "cache", "using cached timezone");
return Ok(tz);
}
// 3. Fetch from Calendar Settings API
match fetch_account_timezone(client, token).await {
Ok(tz) => return Ok(tz),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");
}
}
// 4. Fall back to machine-local timezone
let local_iana = iana_time_zone_fallback();
tracing::warn!(
timezone = local_iana.as_str(),
source = "local_machine",
"using machine-local timezone as fallback"
);
let tz: Tz = local_iana.parse().unwrap_or(chrono_tz::UTC);
Ok(tz)
}
/// Return the start of today (midnight) in the given timezone as a
/// timezone-aware `DateTime`. Errors if midnight cannot be resolved
/// (e.g. a DST transition that skips midnight — extremely rare).
pub fn start_of_today(tz: Tz) -> Result<chrono::DateTime<Tz>, crate::error::GwsError> {
use chrono::{NaiveTime, TimeZone, Utc};
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
tz.from_local_datetime(&today_start)
.earliest()
.ok_or_else(|| {
crate::error::GwsError::Other(anyhow::anyhow!(
"Could not determine start of day in timezone '{}'",
tz
))
})
}
/// Best-effort machine-local IANA timezone detection using the
/// `iana-time-zone` crate, which reads the OS timezone database.
fn iana_time_zone_fallback() -> String {
match iana_time_zone::get_timezone() {
Ok(tz) => tz,
Err(_) => "UTC".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_iana_timezone() {
let tz = parse_timezone("America/Denver").unwrap();
assert_eq!(tz, chrono_tz::America::Denver);
}
#[test]
fn parse_utc_timezone() {
let tz = parse_timezone("UTC").unwrap();
assert_eq!(tz, chrono_tz::UTC);
}
#[test]
fn parse_invalid_timezone_fails() {
let result = parse_timezone("Not/A/Zone");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid timezone"));
assert!(err.contains("Not/A/Zone"));
}
#[test]
fn parse_empty_string_fails() {
let result = parse_timezone("");
assert!(result.is_err());
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let cache_file = dir.path().join(CACHE_FILENAME);
// Write directly to test location
std::fs::write(&cache_file, "America/New_York").unwrap();
let contents = std::fs::read_to_string(&cache_file).unwrap();
let tz: Tz = contents.trim().parse().unwrap();
assert_eq!(tz, chrono_tz::America::New_York);
}
#[test]
fn iana_fallback_returns_valid_tz() {
let tz_name = iana_time_zone_fallback();
// Should be parseable
let result: Result<Tz, _> = tz_name.parse();
assert!(
result.is_ok(),
"Fallback timezone '{tz_name}' should be parseable"
);
}
}
@@ -0,0 +1,175 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use yup_oauth2::storage::{TokenInfo, TokenStorage, TokenStorageError};
use crate::output::sanitize_for_terminal;
/// A custom token storage implementation for `yup-oauth2` that encrypts
/// the cached tokens at rest using AES-256-GCM encryption.
pub struct EncryptedTokenStorage {
file_path: PathBuf,
// Add memory cache since TokenStorage getters can be called frequently
cache: Arc<Mutex<Option<HashMap<String, TokenInfo>>>>,
}
impl EncryptedTokenStorage {
pub fn new(path: PathBuf) -> Self {
Self {
file_path: path,
cache: Arc::new(Mutex::new(None)),
}
}
async fn load_from_disk(&self) -> HashMap<String, TokenInfo> {
let data = match tokio::fs::read(&self.file_path).await {
Ok(d) => d,
Err(_) => return HashMap::new(), // File doesn't exist yet — normal on first run
};
let decrypted = match crate::credential_store::decrypt(&data) {
Ok(d) => d,
Err(e) => {
eprintln!(
"warning: failed to decrypt token cache ({}): {e:#}",
self.file_path.display()
);
eprintln!("hint: you may need to re-authenticate with `gws auth login`");
return HashMap::new();
}
};
let json = match String::from_utf8(decrypted) {
Ok(j) => j,
Err(e) => {
eprintln!(
"warning: token cache contains invalid UTF-8: {}",
sanitize_for_terminal(&e.to_string())
);
return HashMap::new();
}
};
match serde_json::from_str(&json) {
Ok(map) => map,
Err(e) => {
eprintln!(
"warning: failed to parse token cache JSON: {}",
sanitize_for_terminal(&e.to_string())
);
HashMap::new()
}
}
}
async fn save_to_disk(&self, map: &HashMap<String, TokenInfo>) -> anyhow::Result<()> {
let json = serde_json::to_string(map)?;
let encrypted = crate::credential_store::encrypt(json.as_bytes())?;
if let Some(parent) = self.file_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
anyhow::anyhow!(
"Failed to create token directory '{}': {}",
sanitize_for_terminal(&parent.display().to_string()),
e
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to set permissions on token directory '{}': {}",
sanitize_for_terminal(&parent.display().to_string()),
e
)
})?;
}
}
// Write atomically via a sibling .tmp file + rename.
crate::fs_util::atomic_write_async(&self.file_path, encrypted.as_slice()).await?;
Ok(())
}
// Helper to join scopes consistently for cache keys
fn cache_key(scopes: &[&str]) -> String {
let mut s: Vec<&str> = scopes.to_vec();
s.sort_unstable();
s.dedup();
s.join(" ")
}
}
#[async_trait::async_trait]
impl TokenStorage for EncryptedTokenStorage {
async fn set(&self, scopes: &[&str], token: TokenInfo) -> Result<(), TokenStorageError> {
let mut map_lock = self.cache.lock().await;
// Initialize cache if this is the first write
if map_lock.is_none() {
*map_lock = Some(self.load_from_disk().await);
}
if let Some(map) = map_lock.as_mut() {
map.insert(Self::cache_key(scopes), token);
self.save_to_disk(map)
.await
.map_err(|e| TokenStorageError::Other(std::borrow::Cow::Owned(e.to_string())))?;
}
Ok(())
}
async fn get(&self, scopes: &[&str]) -> Option<TokenInfo> {
let mut map_lock = self.cache.lock().await;
if map_lock.is_none() {
*map_lock = Some(self.load_from_disk().await);
}
if let Some(map) = map_lock.as_ref() {
let key = Self::cache_key(scopes);
if let Some(token) = map.get(&key) {
return Some(token.clone());
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[tokio::test]
async fn test_encrypted_token_storage_new() {
let path = PathBuf::from("/fake/path/to/token.json");
let storage = EncryptedTokenStorage::new(path.clone());
assert_eq!(storage.file_path, path);
let cache_lock = storage.cache.lock().await;
assert!(cache_lock.is_none());
}
}
@@ -0,0 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Input validation — re-exports from `google_workspace` library crate.
pub use google_workspace::validate::*;
@@ -0,0 +1,8 @@
{
"filterConfig": {
"piAndJailbreakFilterSettings": {
"filterEnforcement": "ENABLED",
"confidenceLevel": "LOW_AND_ABOVE"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "google-workspace"
version = "0.22.5"
edition = "2021"
description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["google-workspace", "google", "discovery", "api-client"]
categories = ["api-bindings", "web-programming"]
[dependencies]
anyhow = "1"
percent-encoding = "2.3.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["time", "fs"] }
tracing = "0.1"
[dev-dependencies]
serial_test = "3.4.0"
tempfile = "3"
+40
View File
@@ -0,0 +1,40 @@
# google-workspace
Core Rust library for interacting with Google Workspace APIs via the [Discovery Service](https://developers.google.com/discovery).
This crate provides the foundational types and utilities used by the [`google-workspace-cli`](https://crates.io/crates/google-workspace-cli) (`gws`) command-line tool, and can be used independently for programmatic access.
> **Dynamic Discovery** — this library fetches Google's Discovery Documents at runtime rather than relying on generated client crates. When Google adds or updates an API endpoint, your code picks it up automatically.
## Modules
| Module | Description |
|---|---|
| `discovery` | Discovery Document types (`RestDescription`, `RestMethod`, etc.) and async fetch with optional disk caching |
| `services` | Service registry mapping aliases (e.g., `drive`) to API name/version pairs |
| `error` | Structured `GwsError` enum with exit codes and JSON serialization |
| `validate` | Input validation: path safety, resource name checks, URL encoding |
| `client` | HTTP client builder with automatic retry logic |
## Usage
```rust
use google_workspace::discovery::fetch_discovery_document;
use google_workspace::services::resolve_service;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (api, version) = resolve_service("drive").unwrap();
let doc = fetch_discovery_document(api, version, None).await?;
println!("{} {}{} resources",
doc.name, doc.version,
doc.resources.len(),
);
Ok(())
}
```
## License
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! HTTP client with retry logic for Google API requests.
use std::sync::OnceLock;
use reqwest::header::{HeaderMap, HeaderValue};
const MAX_RETRIES: u32 = 3;
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
/// or misconfigured server from hanging the process indefinitely.
const MAX_RETRY_DELAY_SECS: u64 = 60;
const CONNECT_TIMEOUT_SECS: u64 = 10;
fn build_client_inner() -> Result<reqwest::Client, String> {
let mut headers = HeaderMap::new();
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
// Format: gl-rust/name-version (the gl-rust/ prefix is fixed)
let client_header = format!("gl-rust/{}-{}", name, version);
if let Ok(header_value) = HeaderValue::from_str(&client_header) {
headers.insert("x-goog-api-client", header_value);
}
reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message)))
}
/// Returns a shared reqwest client clone backed by a single global connection pool.
///
/// `reqwest::Client` is cheap to clone, so callers can take ownership of the
/// returned value while still sharing pooled connections underneath.
pub fn shared_client() -> Result<reqwest::Client, crate::error::GwsError> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
match CLIENT.get_or_init(build_client_inner) {
Ok(client) => Ok(client.clone()),
Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!(
message.clone()
))),
}
}
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
/// and transient connection/timeout errors.
/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s).
pub async fn send_with_retry(
build_request: impl Fn() -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
let mut last_err: Option<reqwest::Error> = None;
for attempt in 0..MAX_RETRIES {
match build_request().send().await {
Ok(resp) => {
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return Ok(resp);
}
let header_value = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok());
let retry_after = compute_retry_delay(header_value, attempt);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
}
Err(e) if e.is_connect() || e.is_timeout() => {
// Transient network error — retry with exponential backoff
let delay = compute_retry_delay(None, attempt);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
// Final attempt — return whatever we get
match build_request().send().await {
Ok(resp) => Ok(resp),
Err(e) => Err(last_err.unwrap_or(e)),
}
}
/// Compute the retry delay from a Retry-After header value and attempt number.
/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or
/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS.
fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 {
header_value
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2u64.saturating_pow(attempt))
.min(MAX_RETRY_DELAY_SECS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_client_succeeds() {
assert!(build_client().is_ok());
}
#[test]
fn shared_client_succeeds() {
assert!(shared_client().is_ok());
}
#[test]
fn shared_client_can_be_reused() {
let client_a = shared_client().unwrap();
let client_b = shared_client().unwrap();
let request_a = client_a.get("https://example.com").build().unwrap();
let request_b = client_b.get("https://example.com").build().unwrap();
assert_eq!(request_a.url(), request_b.url());
}
#[test]
fn retry_delay_caps_large_header_value() {
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
}
#[test]
fn retry_delay_passes_through_small_header_value() {
assert_eq!(compute_retry_delay(Some("5"), 0), 5);
}
#[test]
fn retry_delay_falls_back_to_exponential_on_missing_header() {
assert_eq!(compute_retry_delay(None, 0), 1); // 2^0
assert_eq!(compute_retry_delay(None, 1), 2); // 2^1
assert_eq!(compute_retry_delay(None, 2), 4); // 2^2
}
#[test]
fn retry_delay_falls_back_on_unparseable_header() {
assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2);
assert_eq!(compute_retry_delay(Some(""), 0), 1);
}
#[test]
fn retry_delay_caps_at_boundary() {
assert_eq!(compute_retry_delay(Some("60"), 0), 60);
assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS);
}
}

Some files were not shown because too many files have changed in this diff Show More