mirror of
https://github.com/googleworkspace/cli.git
synced 2026-09-14 16:47:13 +08:00
feat: implement cli (#1)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# OAuth Client Credentials
|
||||
# Create these at https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_WORKSPACE_CLI_CLIENT_ID=
|
||||
GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=
|
||||
|
||||
# Authentication
|
||||
# Path to a service account JSON key file or user credentials
|
||||
# GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=
|
||||
|
||||
# Impersonation (Domain-Wide Delegation)
|
||||
# Email address of the user to impersonate when using a service account
|
||||
# GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER=
|
||||
|
||||
# Model Armor Sanitization
|
||||
# Default template resource name for --sanitize
|
||||
# GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE=projects/my-project/locations/us-central1/templates/my-template
|
||||
# Sanitization mode: 'warn' (default) or 'block'
|
||||
# GOOGLE_WORKSPACE_CLI_SANITIZE_MODE=warn
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
area: schema:
|
||||
- 'src/schema.rs'
|
||||
area: auth:
|
||||
- 'src/auth.rs'
|
||||
area: execution:
|
||||
- 'src/executor.rs'
|
||||
- 'src/commands.rs'
|
||||
- 'src/main.rs'
|
||||
area: discovery:
|
||||
- 'src/discovery.rs'
|
||||
skill: docs:
|
||||
- 'src/helpers/docs.rs'
|
||||
skill: drive:
|
||||
- 'src/helpers/drive.rs'
|
||||
skill: events:
|
||||
- 'src/helpers/events.rs'
|
||||
skill: gmail:
|
||||
- 'src/helpers/gmail.rs'
|
||||
skill: script:
|
||||
- 'src/helpers/script.rs'
|
||||
skill: sheets:
|
||||
- 'src/helpers/sheets.rs'
|
||||
core: docs:
|
||||
- '**/*.md'
|
||||
core: ci:
|
||||
- '.github/**/*'
|
||||
@@ -0,0 +1,271 @@
|
||||
# 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]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- 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 -- -D warnings
|
||||
|
||||
policy-check:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Enforce AGENTS.md rules
|
||||
run: |
|
||||
if grep -qE "^google-[a-zA-Z0-9_-]+[[:space:]]*=" Cargo.toml; then
|
||||
echo "::error file=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 }}
|
||||
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!"
|
||||
|
||||
skills:
|
||||
name: Verify Skills
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-skills-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Regenerate skills
|
||||
run: cargo run -- generate-skills --output-dir skills
|
||||
|
||||
- name: Check for drift
|
||||
run: |
|
||||
if ! git diff --exit-code skills/; then
|
||||
echo "::error::Skills are out of date. Run 'cargo run -- generate-skills' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- name: Generate code coverage
|
||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- 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@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- 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: 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
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-smoketest-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- 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: ./target/release/gws --help
|
||||
|
||||
- name: Smoketest — schema introspection
|
||||
run: ./target/release/gws schema drive.files.list | jq -e '.httpMethod'
|
||||
|
||||
- name: Smoketest — Drive files list
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
./target/release/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: |
|
||||
./target/release/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: |
|
||||
./target/release/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: |
|
||||
./target/release/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=$(./target/release/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 ./target/release/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
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@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@v4
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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: "Pull Request Labeler"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: pnpm changeset publish
|
||||
commit: 'chore: release versions'
|
||||
title: 'chore: release versions'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,333 @@
|
||||
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||
#
|
||||
# Copyright 2022-2024, axodotdev
|
||||
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||
#
|
||||
# CI that:
|
||||
#
|
||||
# * checks for a Git Tag that looks like a release
|
||||
# * builds artifacts with dist (archives, installers, hashes)
|
||||
# * uploads those artifacts to temporary workflow zip
|
||||
# * on success, uploads the artifacts to a GitHub Release
|
||||
#
|
||||
# Note that the GitHub Release will be created with a generated
|
||||
# title/body based on your changelogs.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
"contents": "write"
|
||||
|
||||
# This task will run whenever you push a git tag that looks like a version
|
||||
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||
#
|
||||
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||
#
|
||||
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||
# (dist-able) packages in the workspace with that version (this mode is
|
||||
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||
# packages versioned/released in lockstep).
|
||||
#
|
||||
# If you push multiple tags at once, separate instances of this workflow will
|
||||
# spin up, creating an independent announcement for each one. However, GitHub
|
||||
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||
# mistake.
|
||||
#
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
plan:
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.plan.outputs.manifest }}
|
||||
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||
publishing: ${{ !github.event.pull_request }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install dist
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
# sure would be cool if github gave us proper conditionals...
|
||||
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||
- id: plan
|
||||
run: |
|
||||
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Target platforms/runners are computed by dist in create-release.
|
||||
# Each member of the matrix has the following arguments:
|
||||
#
|
||||
# - runner: the github runner
|
||||
# - dist-args: cli flags to pass to dist
|
||||
# - install-dist: expression to run to install dist on the runner
|
||||
#
|
||||
# Typically there will be:
|
||||
# - 1 "global" task that builds universal installers
|
||||
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container: ${{ matrix.container && matrix.container.image || null }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||
permissions:
|
||||
"attestations": "write"
|
||||
"contents": "read"
|
||||
"id-token": "write"
|
||||
steps:
|
||||
- name: enable windows longpaths
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust non-interactively if not already installed
|
||||
if: ${{ matrix.container }}
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
- name: Install dist
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
# Actually do builds and make zips and whatnot
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
- name: Attest
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*"
|
||||
- id: cargo-dist
|
||||
name: Post-build
|
||||
# We force bash here just because github makes it really hard to get values up
|
||||
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||
# inconsistent syntax between shell and powershell.
|
||||
shell: bash
|
||||
run: |
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
# Build and package all the platform-agnostic(ish) things
|
||||
build-global-artifacts:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: cargo-dist
|
||||
shell: bash
|
||||
run: |
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.host.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: host
|
||||
shell: bash
|
||||
run: |
|
||||
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||
echo "artifacts uploaded and released successfully"
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Cleanup
|
||||
run: |
|
||||
# Remove the granular manifests
|
||||
rm -f artifacts/*-dist-manifest.json
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||
RELEASE_COMMIT: "${{ github.sha }}"
|
||||
run: |
|
||||
# Write and read notes from a file to avoid quoting breaking things
|
||||
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
publish-npm:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PLAN: ${{ needs.plan.outputs.val }}
|
||||
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
|
||||
steps:
|
||||
- name: Fetch npm packages
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: npm/
|
||||
merge-multiple: true
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: |
|
||||
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
|
||||
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
|
||||
npm publish --access public "./npm/${pkg}"
|
||||
done
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
- publish-npm
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -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@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'
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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 `src/services.rs` and verify the Discovery URL pattern in `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
|
||||
|
||||
```bash
|
||||
cargo build # Build in dev mode
|
||||
cargo clippy -- -D warnings # Lint check
|
||||
cargo test # Run tests
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
### Source Layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/main.rs` | Entrypoint, two-phase CLI parsing, method resolution |
|
||||
| `src/discovery.rs` | Serde models for Discovery Document + fetch/cache |
|
||||
| `src/services.rs` | Service alias → Discovery API name/version mapping |
|
||||
| `src/auth.rs` | Headless OAuth2 via `yup-oauth2` |
|
||||
| `src/commands.rs` | Recursive `clap::Command` builder from Discovery resources |
|
||||
| `src/executor.rs` | HTTP request construction, response handling, schema validation |
|
||||
| `src/schema.rs` | `gws schema` command — introspect API method schemas |
|
||||
| `src/error.rs` | Structured JSON error output |
|
||||
|
||||
## Demo Videos
|
||||
|
||||
Demo recordings are generated with [VHS](https://github.com/charmbracelet/vhs) (`.tape` files).
|
||||
|
||||
```bash
|
||||
vhs demo.tape # YouTube Short (portrait 1080×1920)
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `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 credentials secured by the OS Keyring and encrypted in `~/.config/gws/`)
|
||||
- Supports `.env` files via `dotenvy`
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
|
||||
## 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
+3622
File diff suppressed because it is too large
Load Diff
+64
@@ -0,0 +1,64 @@
|
||||
# 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 = "gws"
|
||||
version = "0.1.0"
|
||||
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]
|
||||
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"], 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"
|
||||
base64 = "0.22.1"
|
||||
derive_builder = "0.20.2"
|
||||
ratatui = "0.30.0"
|
||||
crossterm = "0.29.0"
|
||||
chrono = "0.4.44"
|
||||
keyring = "3.6.3"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
|
||||
# The profile that 'cargo dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.4.0"
|
||||
tempfile = "3"
|
||||
@@ -1,8 +1,260 @@
|
||||
# gws — Google Workspace CLI
|
||||
|
||||
A CLI that generates its entire command surface dynamically from Google Discovery Service JSON documents.
|
||||
A CLI that generates its entire command surface dynamically from Google Discovery Service JSON documents. Includes skills for AI agents.
|
||||
|
||||
## License
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @googleworkspace/cli
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## AI Agents & Skills
|
||||
|
||||
This repository includes [Agent Skills](https://github.com/vercel-labs/agent-skills) definitions (`SKILL.md`) for every supported Google Workspace API. Skills are prefixed with `gws-` to avoid namespace collisions when installed globally.
|
||||
|
||||
You can install these skills directly into your AI agent using `npx`:
|
||||
|
||||
```bash
|
||||
# Add all Google Workspace skills to your agent
|
||||
npx skills add github:googleworkspace/cli
|
||||
```
|
||||
|
||||
Or add specific skills by path:
|
||||
|
||||
```bash
|
||||
# Add the shared skill (authentication, etc.)
|
||||
npx skills add https://github.com/googleworkspace/cli/tree/main/skills/gws-shared
|
||||
|
||||
# Add only Google Drive and Gmail skills
|
||||
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
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
|
||||
Clone the repo and copy (or symlink) the skills into your OpenClaw skills directory:
|
||||
|
||||
```bash
|
||||
# All skills
|
||||
cp -r skills/gws-* ~/.openclaw/skills/
|
||||
|
||||
# Or symlink for easy updates
|
||||
ln -s $(pwd)/skills/gws-* ~/.openclaw/skills/
|
||||
```
|
||||
|
||||
Or copy only specific skills:
|
||||
|
||||
```bash
|
||||
cp -r skills/gws-drive skills/gws-gmail ~/.openclaw/skills/
|
||||
```
|
||||
|
||||
The `gws-shared` skill includes an `install` block so OpenClaw can auto-install the CLI via `npm i -g @googleworkspace/cli` if the `gws` binary isn't found on PATH.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# List files in Drive
|
||||
gws drive files list --params '{"pageSize": 10}'
|
||||
|
||||
# Get a file's metadata
|
||||
gws drive files get --params '{"fileId": "abc123"}'
|
||||
|
||||
# Create a spreadsheet
|
||||
gws sheets spreadsheets create --json '{"properties": {"title": "My Sheet"}}'
|
||||
|
||||
# List Gmail messages
|
||||
gws gmail users messages list --params '{"userId": "me"}'
|
||||
|
||||
# Introspect a method's schema
|
||||
gws schema drive.files.list
|
||||
|
||||
# Dynamic help for any resource
|
||||
gws drive files --help
|
||||
gws drive files list --help
|
||||
|
||||
# Preview a request without sending it
|
||||
gws chat spaces messages create \
|
||||
--params '{"parent": "spaces/xyz"}' \
|
||||
--json '{"text": "Hello world"}' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The CLI supports three primary authentication workflows depending on your environment.
|
||||
|
||||
### 1. Interactive Auth (Local Desktop)
|
||||
|
||||
For interactive use on your personal machine where a web browser is available.
|
||||
|
||||
**Security**: By default, credentials and access tokens are encrypted at rest using AES-256-GCM. The encryption key is stored securely in your OS Keyring (Apple Keychain, Secret Service, or Windows Credential Manager). If a keyring is unavailable (e.g., headless Linux), it falls back to a strictly permissioned (`0600`) local key file.
|
||||
|
||||
**Google Cloud Setup & Login:**
|
||||
The CLI includes a built-in setup wizard to help you configure your Google Cloud Project, enable APIs, and generate the necessary OAuth credentials. Note that this requires the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) to be installed and authenticated (`gcloud auth login`).
|
||||
|
||||
```bash
|
||||
# Run the interactive setup and login wizard
|
||||
gws setup
|
||||
|
||||
# Or login directly if you already have client_secret.json configured
|
||||
gws auth login
|
||||
|
||||
# Or login with custom scopes
|
||||
gws auth login --scopes "https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.readonly"
|
||||
```
|
||||
|
||||
### 2. Headless & CI/CD Auth (Export Flow)
|
||||
|
||||
For remote servers, SSH sessions, or CI/CD pipelines where a browser is unavailable, use the export flow.
|
||||
|
||||
1. On your **local machine** (with a browser), complete the Interactive Auth steps above.
|
||||
2. Export your credentials to a portable JSON format:
|
||||
```bash
|
||||
gws auth export --unmasked > credentials.json
|
||||
```
|
||||
3. On your **headless machine**, securely transfer `credentials.json` and point the CLI to it. The CLI will automatically use this payload to mint fresh access tokens.
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Commands now work headlessly!
|
||||
gws drive files list
|
||||
```
|
||||
|
||||
*Note: You can also strictly provide a short-lived access token directly via environment variable (e.g. `export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)`), though this token will naturally expire in ~1 hour.*
|
||||
|
||||
### 3. Service Account Auth (Server-to-Server)
|
||||
|
||||
For automated programmatic access. Point `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` to your service account JSON key file. No login step is required.
|
||||
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/service-account.json
|
||||
gws drive files list
|
||||
```
|
||||
|
||||
**Domain-Wide Delegation (Impersonation)**
|
||||
If your service account has Domain-Wide Delegation enabled, you can impersonate a Workspace user (e.g., an admin) to perform actions on their behalf.
|
||||
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER=user@example.com
|
||||
```
|
||||
|
||||
### 4. Pre-obtained Access Token (CI/CD or External)
|
||||
|
||||
The simplest way to authenticate if you already possess a short-lived access token. This is often used in CI/CD pipelines where another tool (like `gcloud`) mints the token for the environment.
|
||||
|
||||
```bash
|
||||
# Obtain a token using the gcloud CLI
|
||||
export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)
|
||||
gws drive files list
|
||||
```
|
||||
*(Note: These raw access tokens typically expire in ~1 hour).*
|
||||
|
||||
---
|
||||
|
||||
### Auth Precedence Order
|
||||
|
||||
The CLI evaluates authentication sources in the following strict order:
|
||||
|
||||
| Priority | Source | How to set |
|
||||
|----------|--------|------------|
|
||||
| 1 (highest) | Raw access token | `GOOGLE_WORKSPACE_CLI_TOKEN` env var |
|
||||
| 2 | Credentials file (user or service account) | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var |
|
||||
| 3 | Encrypted credentials & token cache | `~/.config/gws/credentials.enc` and `token_cache.json` (created by `gws auth login`, secured via OS Keyring) |
|
||||
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
|
||||
| — | No auth | Proceeds unauthenticated; shows error if the API rejects |
|
||||
|
||||
*(Note: Environment variables can also be set via a `.env` file in the working directory.)*
|
||||
|
||||
## Architecture
|
||||
|
||||
The CLI uses a **two-phase argument parsing** strategy:
|
||||
|
||||
1. Extract the service name from `argv[1]`
|
||||
2. Fetch the service's Discovery Document (cached for 24h)
|
||||
3. Build a dynamic `clap::Command` tree from the document's resources/methods
|
||||
4. Re-parse the remaining arguments against the tree
|
||||
5. Authenticate, construct the HTTP request, and execute
|
||||
|
||||
All output (success, error, file download metadata) is structured JSON for AI agent consumption. Binary outputs require an `--output` flag.
|
||||
|
||||
There are a few special behaviors to be aware of that diverge from the Discovery Service API representation:
|
||||
|
||||
### Multipart uploads
|
||||
|
||||
For multipart uploads (e.g. Drive file uploads), use the `--upload` flag to specify the path to the file to upload.
|
||||
|
||||
```bash
|
||||
gws drive files create --json '{"name": "My File"}' --upload /path/to/file
|
||||
```
|
||||
|
||||
### Pagination and NDJSON
|
||||
|
||||
Use `--page-all` to auto-paginate through results. Each page is emitted as a single JSON line (NDJSON), making it easy to stream into tools like `jq`.
|
||||
|
||||
| Flag | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `--page-all` | Auto-paginate, one JSON line per page | off |
|
||||
| `--page-limit <N>` | Max pages to fetch | 10 |
|
||||
| `--page-delay <MS>` | Delay between pages in ms | 100 |
|
||||
|
||||
```bash
|
||||
# Stream all Drive files as NDJSON
|
||||
gws drive files list --params '{"pageSize": 100}' --page-all --page-limit 5
|
||||
|
||||
# Pipe to jq to extract file names
|
||||
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
|
||||
```
|
||||
|
||||
## Testing & Coverage
|
||||
|
||||
Run unit tests:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Generate code coverage report (requires `cargo-llvm-cov`):
|
||||
```bash
|
||||
./scripts/coverage.sh
|
||||
```
|
||||
The report will be available at `target/llvm-cov/html/index.html`.
|
||||
|
||||
## Security & Sanitization (Model Armor)
|
||||
|
||||
The CLI integrates with **Google Cloud Model Armor** to sanitize API responses for prompt injection risks before they reach your AI agent.
|
||||
|
||||
```bash
|
||||
# Sanitize a specific command
|
||||
gws gmail users messages get --params '...' \
|
||||
--sanitize "projects/P/locations/L/templates/T"
|
||||
```
|
||||
|
||||
This checks the *entire* JSON response against the specified Model Armor template.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can set default behavior via environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template resource name |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block`. |
|
||||
|
||||
- **Warn mode**: Prints a warning to stderr and annotates the JSON with `_sanitization` details.
|
||||
- **Block mode**: Suppresses the output entirely and exits with an error if a match is found.
|
||||
|
||||
### Requirements
|
||||
|
||||
Using `--sanitize` requires the `https://www.googleapis.com/auth/cloud-platform` scope.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
|
||||
@@ -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 ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════╝
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -0,0 +1,32 @@
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🤖 WHAT IS GWS?
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
A single CLI for ALL Google Workspace APIs.
|
||||
|
||||
Perfect for:
|
||||
|
||||
🤖 AI agents
|
||||
📜 Shell scripts
|
||||
⚡ Power users
|
||||
📊 Automation
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📂 EXPLORE SERVICES
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔍 INSPECT DRIVE
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,7 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔍 INTROSPECT APIS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
What params does an API
|
||||
method accept? Just ask.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📊 JSON SCHEMAS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🗂️ List files in a folder
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📤 Upload to Drive
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📧 Send an email
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📅 Schedule a meeting
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📊 Log data to Sheets
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
♾️ Paginate all pages
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
--page-all streams NDJSON
|
||||
from every page.
|
||||
Pipe to jq for processing.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# GWS CLI — YouTube Short Demo (FAST iteration mode)
|
||||
# Run: vhs demo.tape
|
||||
#
|
||||
# All cosmetic sleeps minimized. API sleeps kept for responses.
|
||||
# Single line commands for reliability.
|
||||
|
||||
Output demo.mp4
|
||||
Output demo.gif
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 22
|
||||
Set Width 1080
|
||||
Set Height 1920
|
||||
Set TypingSpeed 1ms
|
||||
Set Padding 30
|
||||
Set LineHeight 1.3
|
||||
|
||||
# ── Setup (hidden) ──
|
||||
Hide
|
||||
# Mock gemini CLI for deterministic demo
|
||||
Type 'function gemini() { echo "Why do Java developers wear glasses? Because they don'"'"'t C#."; }' Enter
|
||||
Type "export -f gemini" Enter
|
||||
Type "export PATH=$PWD/target/release:$PWD/target/debug:$PATH" Enter
|
||||
Type "set -e" Enter
|
||||
Sleep 1s
|
||||
Type `DEMO=$(gws drive files create --json '{"name":"gws-demo","mimeType":"application/vnd.google-apps.folder"}' | jq -r '.id')` Enter
|
||||
Sleep 3s
|
||||
Type `gws drive files create --json "{\"name\":\"meeting-notes.md\",\"mimeType\":\"text/markdown\",\"parents\":[\"$DEMO\"]}" > /dev/null` Enter
|
||||
Sleep 2s
|
||||
Type `gws drive files create --json "{\"name\":\"quarterly-report.csv\",\"mimeType\":\"text/csv\",\"parents\":[\"$DEMO\"]}" > /dev/null` Enter
|
||||
Sleep 2s
|
||||
Type `gws drive files create --json "{\"name\":\"project-roadmap.md\",\"mimeType\":\"text/markdown\",\"parents\":[\"$DEMO\"]}" > /dev/null` Enter
|
||||
Sleep 2s
|
||||
Type `Q="'$DEMO' in parents"` Enter
|
||||
Sleep 500ms
|
||||
Type "clear" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
|
||||
# ╔══════════════════════════════════════╗
|
||||
# ║ ASCII ART INTRO ║
|
||||
# ╚══════════════════════════════════════╝
|
||||
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/intro.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
# ── Scene 1: What is gws? ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene1.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 4s
|
||||
|
||||
# ── Scene 2: Discover all services ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene2.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws --help 2>&1 | head -40" Enter
|
||||
Sleep 4s
|
||||
|
||||
# ── Scene 2b: Inspect Drive resources ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene2b.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws drive --help 2>&1 | head -40" Enter
|
||||
Sleep 4s
|
||||
|
||||
# ── Scene 3: Schema introspection ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene3.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws schema drive.files.list | head -15" Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 3b: Inspect JSON Schemas ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene3b.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws schema drive.File | head -20" Enter
|
||||
Sleep 3s
|
||||
|
||||
Type "gws schema drive.File --resolve-refs | head -30" Enter
|
||||
Sleep 5s
|
||||
|
||||
# ── Scene 4: List files in a folder ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene4.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws drive files list"
|
||||
Type ` --params "{\"q\":\"$Q\",\"fields\":\"files(name,mimeType)\"}"` Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 5: Upload a file ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene5.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "echo '# Notes' > /tmp/notes.md" Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "gws drive files create"
|
||||
Type ` --json '{"name":"notes.md","mimeType":"text/markdown"}'`
|
||||
Type " --upload /tmp/notes.md" Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 6: Gmail labels ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene6.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "JOKE=$(gemini 'tell me a joke')" Enter
|
||||
Sleep 1s
|
||||
|
||||
Type `MSG=$(echo -e "To: justin@example.com\nSubject: joke of the day\n\n$JOKE" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')` Enter
|
||||
|
||||
Type "gws gmail users messages send"
|
||||
Type ` --params '{"userId":"me"}'`
|
||||
Type ` --json "{\"raw\":\"$MSG\"}"`
|
||||
Type " | jq ." Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 7: Calendar event ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene7.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws calendar events insert"
|
||||
Type ` --params '{"calendarId":"primary"}'`
|
||||
Type ` --json '{"summary":"Ship v1.0 🚀","start":{"dateTime":"2024-06-17T10:00:00-07:00"},"end":{"dateTime":"2024-06-17T10:30:00-07:00"}}'`
|
||||
Type " | jq . | head -15" Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 8: Sheets automation ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene8.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws sheets spreadsheets values append"
|
||||
Type ` --params '{"spreadsheetId":"1izmtvgBC4NuxHhABFX6descuB6-SXTm3g7c6LYBngJQ","range":"Sheet1!A1","valueInputOption":"USER_ENTERED"}'`
|
||||
Type ` --json '{"values":[["Deploy","v1.0","=NOW()"]]}'` Enter
|
||||
Sleep 3s
|
||||
|
||||
# ── Scene 9: Auto-pagination ──
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/scene9.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
Type "gws drive files list"
|
||||
Type " --params '" Sleep 50ms
|
||||
Type@30ms '{"pageSize":2,"fields":"nextPageToken,files(id)"}' Sleep 50ms
|
||||
Type "'"
|
||||
Type " --page-all"
|
||||
Type " | jq -r '.files[]?.id'" Enter
|
||||
Sleep 6s
|
||||
|
||||
# ╔══════════════════════════════════════╗
|
||||
# ║ OUTRO ║
|
||||
# ╚══════════════════════════════════════╝
|
||||
|
||||
Hide
|
||||
Type "./scripts/show-art.sh art/outro.txt" Enter
|
||||
Sleep 1s
|
||||
Show
|
||||
Sleep 1s
|
||||
|
||||
# ── Cleanup (hidden) ──
|
||||
Hide
|
||||
Type `gws drive files delete --params "{\"fileId\":\"$DEMO\"}" > /dev/null 2>&1` Enter
|
||||
Sleep 3s
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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 = ["cargo:."]
|
||||
|
||||
# Config for 'cargo dist'
|
||||
[dist]
|
||||
# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax)
|
||||
cargo-dist-version = "0.31.0"
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
installers = ["shell", "powershell", "npm"]
|
||||
# Publish jobs to run
|
||||
publish-jobs = ["npm"]
|
||||
scope = "@googleworkspace"
|
||||
# Enable github attestations
|
||||
github-attestations = true
|
||||
package = "cli"
|
||||
# Target platforms to build apps for (Rust target-triple syntax)
|
||||
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
||||
# Which actions to run on pull requests
|
||||
pr-run-mode = "plan"
|
||||
# The archive format to use for windows builds (defaults .zip)
|
||||
windows-archive = ".tar.gz"
|
||||
# The archive format to use for non-windows builds (defaults .tar.xz)
|
||||
unix-archive = ".tar.gz"
|
||||
+26
-1
@@ -30,4 +30,29 @@ This project follows
|
||||
All submissions, including submissions by project members, require review. We
|
||||
use GitHub pull requests for this purpose. Consult
|
||||
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
|
||||
information on using pull requests.
|
||||
information on using pull requests.
|
||||
|
||||
### Updating CI Smoketest Credentials
|
||||
|
||||
If the OAuth refresh token used in the GitHub Actions smoketest expires or needs additional scopes, you can generate a new one and update the repository secret using the GitHub CLI (`gh`).
|
||||
|
||||
1. **Set the credentials file path to output plaintext JSON**:
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=smoketest-creds.json
|
||||
```
|
||||
|
||||
2. **Authenticate with the required scopes**:
|
||||
```bash
|
||||
cargo run -- auth login --scopes https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/calendar.readonly,https://www.googleapis.com/auth/presentations.readonly,https://www.googleapis.com/auth/tasks.readonly
|
||||
```
|
||||
|
||||
3. **Export and set the GitHub actions secret**:
|
||||
```bash
|
||||
cargo run --quiet -- auth export --unmasked | base64 | gh secret set GOOGLE_CREDENTIALS_JSON
|
||||
```
|
||||
|
||||
4. **Clean up**:
|
||||
```bash
|
||||
rm smoketest-creds.json
|
||||
unset GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "google-workspace-cli",
|
||||
"version": "latest",
|
||||
"description": "CLI tool for managing Google Workspace resources dynamically using Discovery APIs.",
|
||||
"contextFileName": "CONTEXT.md",
|
||||
"settings": [
|
||||
{
|
||||
"name": "Credentials File",
|
||||
"description": "Path to the Google Workspace authorized user credentials or service account JSON file.",
|
||||
"envVar": "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE",
|
||||
"sensitive": false
|
||||
},
|
||||
{
|
||||
"name": "OAuth Client ID",
|
||||
"description": "Client ID for OAuth authentication.",
|
||||
"envVar": "GOOGLE_WORKSPACE_CLI_CLIENT_ID",
|
||||
"sensitive": false
|
||||
},
|
||||
{
|
||||
"name": "OAuth Client Secret",
|
||||
"description": "Client Secret for OAuth authentication.",
|
||||
"envVar": "GOOGLE_WORKSPACE_CLI_CLIENT_SECRET",
|
||||
"sensitive": true
|
||||
},
|
||||
{
|
||||
"name": "Sanitize Template",
|
||||
"description": "Resource name of the Model Armor template to use for sanitization (e.g., projects/P/locations/L/templates/T).",
|
||||
"envVar": "GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE",
|
||||
"sensitive": false
|
||||
},
|
||||
{
|
||||
"name": "Impersonated User",
|
||||
"description": "Email address of the user to impersonate (requires Domain-Wide Delegation).",
|
||||
"envVar": "GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER",
|
||||
"sensitive": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
fmt:
|
||||
glob: "*.rs"
|
||||
run: cargo fmt -- --check
|
||||
clippy:
|
||||
glob: "*.rs"
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
pre-push:
|
||||
parallel: true
|
||||
commands:
|
||||
test:
|
||||
glob: "*.rs"
|
||||
run: cargo test
|
||||
check:
|
||||
glob: "*.rs"
|
||||
run: cargo check
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@googleworkspace/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Google Workspace CLI — dynamic command surface from Discovery Service",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleworkspace/cli.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Justin Poehnelt",
|
||||
"email": "justin.poehnelt@mgail.com"
|
||||
},
|
||||
"homepage": "https://github.com/googleworkspace/cli",
|
||||
"bugs": {
|
||||
"url": "https://github.com/googleworkspace/cli/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "cargo test",
|
||||
"prepare": "lefthook install"
|
||||
},
|
||||
"publishConfig": {
|
||||
"provenance": true
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "pnpm@10.0.0",
|
||||
"keywords": [
|
||||
"cli",
|
||||
"rust",
|
||||
"google-workspace",
|
||||
"google",
|
||||
"drive",
|
||||
"gmail",
|
||||
"sheets",
|
||||
"calendar",
|
||||
"discovery-api",
|
||||
"skills"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"lefthook": "^2.1.2"
|
||||
}
|
||||
}
|
||||
Generated
+912
@@ -0,0 +1,912 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.29.8
|
||||
version: 2.29.8
|
||||
lefthook:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==}
|
||||
|
||||
'@changesets/assemble-release-plan@6.0.9':
|
||||
resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==}
|
||||
|
||||
'@changesets/changelog-git@0.2.1':
|
||||
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
|
||||
|
||||
'@changesets/cli@2.29.8':
|
||||
resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==}
|
||||
hasBin: true
|
||||
|
||||
'@changesets/config@3.1.2':
|
||||
resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==}
|
||||
|
||||
'@changesets/errors@0.2.0':
|
||||
resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==}
|
||||
|
||||
'@changesets/get-dependents-graph@2.1.3':
|
||||
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
|
||||
|
||||
'@changesets/get-release-plan@4.0.14':
|
||||
resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==}
|
||||
|
||||
'@changesets/get-version-range-type@0.4.0':
|
||||
resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==}
|
||||
|
||||
'@changesets/git@3.0.4':
|
||||
resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==}
|
||||
|
||||
'@changesets/logger@0.1.1':
|
||||
resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==}
|
||||
|
||||
'@changesets/parse@0.4.2':
|
||||
resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==}
|
||||
|
||||
'@changesets/pre@2.0.2':
|
||||
resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==}
|
||||
|
||||
'@changesets/read@0.6.6':
|
||||
resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==}
|
||||
|
||||
'@changesets/should-skip-package@0.1.2':
|
||||
resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==}
|
||||
|
||||
'@changesets/types@4.1.0':
|
||||
resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==}
|
||||
|
||||
'@changesets/types@6.1.0':
|
||||
resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==}
|
||||
|
||||
'@changesets/write@0.4.0':
|
||||
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
|
||||
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
||||
|
||||
'@manypkg/get-packages@1.1.3':
|
||||
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/fs.stat@2.0.5':
|
||||
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/fs.walk@1.2.8':
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@types/node@12.20.55':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
array-union@2.1.0:
|
||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chardet@2.1.1:
|
||||
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
||||
|
||||
ci-info@3.9.0:
|
||||
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
detect-indent@6.1.0:
|
||||
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
enquirer@2.4.1:
|
||||
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
esprima@4.0.1:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
extendable-error@0.1.7:
|
||||
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
fs-extra@7.0.1:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
fs-extra@8.1.0:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
globby@11.1.0:
|
||||
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
human-id@4.1.3:
|
||||
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
|
||||
hasBin: true
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-subdir@1.2.0:
|
||||
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
is-windows@1.0.2:
|
||||
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
js-yaml@3.14.2:
|
||||
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
lefthook-darwin-arm64@2.1.2:
|
||||
resolution: {integrity: sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lefthook-darwin-x64@2.1.2:
|
||||
resolution: {integrity: sha512-exooc9Ectz13OLJJOXM9AzaFQbqzf9QCF8JuVvGfbr4RYABYK+BwwtydjlPQrA76/n/h4tsS11MH5bBULnLkYA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lefthook-freebsd-arm64@2.1.2:
|
||||
resolution: {integrity: sha512-E1QMlJPEU21n9eewv6ePfh+JmoTSg5R1jaYcKCky10kfbMdohNucI3xV91F2LcerE+p3UejKDqr/1wWO2RMGeQ==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
lefthook-freebsd-x64@2.1.2:
|
||||
resolution: {integrity: sha512-/5zp+x8055Thj46x9S7hgnneZxvWhHQvPWkkgISCab1Lh6eLrbxvhE1qTb1lU3DqTnNmH9NeXdq1xPHc9uGluA==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lefthook-linux-arm64@2.1.2:
|
||||
resolution: {integrity: sha512-UK5FvDTkwKO7tOznY8iEZzuTsM1jXMZAG5BMRs7olN1k1K6m2unR6oKABP0hCd0wDErK6DZKDJDJfB564Rzqtw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lefthook-linux-x64@2.1.2:
|
||||
resolution: {integrity: sha512-4eOtz4PNh8GbJ+nA8YVDfW/eMirQWdZqMP/V/MVtoVBGobf6oXvvuDOySvAPOgNYEFN0Boegytmuji/851Vstg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lefthook-openbsd-arm64@2.1.2:
|
||||
resolution: {integrity: sha512-lJXRJ6iJIBKwomuNBA3CUNSclj2/rKuxGAQoUra214B92VB6jL9zaY5YEs6h/ie9jQrzSnllEeg7xyDIsuVCrQ==}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
lefthook-openbsd-x64@2.1.2:
|
||||
resolution: {integrity: sha512-GyOje4W0DIqkmR7/Of5D+mZ0vWqMvtGAVedtJR6d1239xNeMzCS8Q+/a3O1xigceZa5xhlqq0BWlssB/QYPQnA==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
lefthook-windows-arm64@2.1.2:
|
||||
resolution: {integrity: sha512-MZKMqTULEpX/8N3fKXAR0A9RjsGKkEEY0japLqrHOIpxsJXry1DRz0FvQo2kkY4WW3rtFegV9m6eesOymuDrUg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lefthook-windows-x64@2.1.2:
|
||||
resolution: {integrity: sha512-NZUgObuaSxc0EXAwC/CzkMf7TuQc++GGIk6TLPdaUpoSsNSJSZEwBVz5DtFB1cG+eMkfO/wOKplls+yjimTTtQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lefthook@2.1.2:
|
||||
resolution: {integrity: sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA==}
|
||||
hasBin: true
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash.startcase@4.4.0:
|
||||
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
outdent@0.5.0:
|
||||
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
||||
|
||||
p-filter@2.1.0:
|
||||
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-map@2.1.0:
|
||||
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-manager-detector@0.2.11:
|
||||
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-type@4.0.0:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
pify@4.0.1:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
prettier@2.8.8:
|
||||
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
read-yaml-file@1.1.0:
|
||||
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
resolve-from@5.0.0:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
spawndamnit@3.0.1:
|
||||
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
term-size@2.2.1:
|
||||
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
universalify@0.1.2:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
dependencies:
|
||||
'@changesets/config': 3.1.2
|
||||
'@changesets/get-version-range-type': 0.4.0
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
detect-indent: 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
lodash.startcase: 4.4.0
|
||||
outdent: 0.5.0
|
||||
prettier: 2.8.8
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/assemble-release-plan@6.0.9':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.3
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/changelog-git@0.2.1':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
|
||||
'@changesets/cli@2.29.8':
|
||||
dependencies:
|
||||
'@changesets/apply-release-plan': 7.0.14
|
||||
'@changesets/assemble-release-plan': 6.0.9
|
||||
'@changesets/changelog-git': 0.2.1
|
||||
'@changesets/config': 3.1.2
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.3
|
||||
'@changesets/get-release-plan': 4.0.14
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/pre': 2.0.2
|
||||
'@changesets/read': 0.6.6
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@changesets/write': 0.4.0
|
||||
'@inquirer/external-editor': 1.0.3
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
ansi-colors: 4.1.3
|
||||
ci-info: 3.9.0
|
||||
enquirer: 2.4.1
|
||||
fs-extra: 7.0.1
|
||||
mri: 1.2.0
|
||||
p-limit: 2.3.0
|
||||
package-manager-detector: 0.2.11
|
||||
picocolors: 1.1.1
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
spawndamnit: 3.0.1
|
||||
term-size: 2.2.1
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@changesets/config@3.1.2':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.3
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
fs-extra: 7.0.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
'@changesets/errors@0.2.0':
|
||||
dependencies:
|
||||
extendable-error: 0.1.7
|
||||
|
||||
'@changesets/get-dependents-graph@2.1.3':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
picocolors: 1.1.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/get-release-plan@4.0.14':
|
||||
dependencies:
|
||||
'@changesets/assemble-release-plan': 6.0.9
|
||||
'@changesets/config': 3.1.2
|
||||
'@changesets/pre': 2.0.2
|
||||
'@changesets/read': 0.6.6
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
||||
'@changesets/get-version-range-type@0.4.0': {}
|
||||
|
||||
'@changesets/git@3.0.4':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
is-subdir: 1.2.0
|
||||
micromatch: 4.0.8
|
||||
spawndamnit: 3.0.1
|
||||
|
||||
'@changesets/logger@0.1.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@changesets/parse@0.4.2':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@changesets/pre@2.0.2':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
fs-extra: 7.0.1
|
||||
|
||||
'@changesets/read@0.6.6':
|
||||
dependencies:
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/parse': 0.4.2
|
||||
'@changesets/types': 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
p-filter: 2.1.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@changesets/should-skip-package@0.1.2':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
||||
'@changesets/types@4.1.0': {}
|
||||
|
||||
'@changesets/types@6.1.0': {}
|
||||
|
||||
'@changesets/write@0.4.0':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
human-id: 4.1.3
|
||||
prettier: 2.8.8
|
||||
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
iconv-lite: 0.7.2
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@types/node': 12.20.55
|
||||
find-up: 4.1.0
|
||||
fs-extra: 8.1.0
|
||||
|
||||
'@manypkg/get-packages@1.1.3':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@changesets/types': 4.1.0
|
||||
'@manypkg/find-root': 1.1.0
|
||||
fs-extra: 8.1.0
|
||||
globby: 11.1.0
|
||||
read-yaml-file: 1.1.0
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
run-parallel: 1.2.0
|
||||
|
||||
'@nodelib/fs.stat@2.0.5': {}
|
||||
|
||||
'@nodelib/fs.walk@1.2.8':
|
||||
dependencies:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@types/node@12.20.55': {}
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
array-union@2.1.0: {}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
dependencies:
|
||||
is-windows: 1.0.2
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
chardet@2.1.1: {}
|
||||
|
||||
ci-info@3.9.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
detect-indent@6.1.0: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
enquirer@2.4.1:
|
||||
dependencies:
|
||||
ansi-colors: 4.1.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
esprima@4.0.1: {}
|
||||
|
||||
extendable-error@0.1.7: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
fs-extra@8.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
globby@11.1.0:
|
||||
dependencies:
|
||||
array-union: 2.1.0
|
||||
dir-glob: 3.0.1
|
||||
fast-glob: 3.3.3
|
||||
ignore: 5.3.2
|
||||
merge2: 1.4.1
|
||||
slash: 3.0.0
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
human-id@4.1.3: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-subdir@1.2.0:
|
||||
dependencies:
|
||||
better-path-resolve: 1.0.0
|
||||
|
||||
is-windows@1.0.2: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
js-yaml@3.14.2:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
lefthook-darwin-arm64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-darwin-x64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-freebsd-arm64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-freebsd-x64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-linux-arm64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-linux-x64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-openbsd-arm64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-openbsd-x64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-windows-arm64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook-windows-x64@2.1.2:
|
||||
optional: true
|
||||
|
||||
lefthook@2.1.2:
|
||||
optionalDependencies:
|
||||
lefthook-darwin-arm64: 2.1.2
|
||||
lefthook-darwin-x64: 2.1.2
|
||||
lefthook-freebsd-arm64: 2.1.2
|
||||
lefthook-freebsd-x64: 2.1.2
|
||||
lefthook-linux-arm64: 2.1.2
|
||||
lefthook-linux-x64: 2.1.2
|
||||
lefthook-openbsd-arm64: 2.1.2
|
||||
lefthook-openbsd-x64: 2.1.2
|
||||
lefthook-windows-arm64: 2.1.2
|
||||
lefthook-windows-x64: 2.1.2
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash.startcase@4.4.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
outdent@0.5.0: {}
|
||||
|
||||
p-filter@2.1.0:
|
||||
dependencies:
|
||||
p-map: 2.1.0
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-map@2.1.0: {}
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-manager-detector@0.2.11:
|
||||
dependencies:
|
||||
quansync: 0.2.11
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
prettier@2.8.8: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
read-yaml-file@1.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
js-yaml: 3.14.2
|
||||
pify: 4.0.1
|
||||
strip-bom: 3.0.0
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
spawndamnit@3.0.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
term-size@2.2.1: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
universalify@0.1.2: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# 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.
|
||||
|
||||
set -e
|
||||
|
||||
# Check if cargo-llvm-cov is installed
|
||||
if ! cargo llvm-cov --version &> /dev/null; then
|
||||
echo "cargo-llvm-cov is not installed. Installing..."
|
||||
cargo install cargo-llvm-cov
|
||||
fi
|
||||
|
||||
# Run coverage and generate HTML report
|
||||
echo "Running tests with coverage..."
|
||||
cargo llvm-cov --all-features --workspace --html
|
||||
cargo llvm-cov --all-features --workspace # Print text summary
|
||||
|
||||
echo "Coverage report generated at target/llvm-cov/html/index.html"
|
||||
|
||||
# Open the report if on macOS
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
open target/llvm-cov/html/index.html
|
||||
fi
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# 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.
|
||||
|
||||
clear
|
||||
cat "$1"
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: gws-admin-reports
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to audit logs and usage reports via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws admin-reports --help"
|
||||
---
|
||||
|
||||
# admin-reports (reports_v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws admin-reports <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### activities
|
||||
|
||||
- `list` — Retrieves a list of activities for a specific customer's account and application such as the Admin console application or the Google Drive application. For more information, see the guides for adminis
|
||||
- `watch` — Start receiving notifications for account activities. For more information, see Receiving Push Notifications.
|
||||
|
||||
### channels
|
||||
|
||||
- `stop` — Stop watching resources through this channel.
|
||||
|
||||
### customerUsageReports
|
||||
|
||||
- `get` — Retrieves a report which is a collection of properties and statistics for a specific customer's account. For more information, see the Customers Usage Report guide. For more information about the cust
|
||||
|
||||
### entityUsageReports
|
||||
|
||||
- `get` — Retrieves a report which is a collection of properties and statistics for entities used by users within the account. For more information, see the Entities Usage Report guide. For more information abo
|
||||
|
||||
### userUsageReport
|
||||
|
||||
- `get` — Retrieves a report which is a collection of properties and statistics for a set of users with the account. For more information, see the User Usage Report guide. For more information about the user re
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws admin-reports --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema admin-reports.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: gws-admin
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage users, groups, and devices via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws admin --help"
|
||||
---
|
||||
|
||||
# admin (directory_v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws admin <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### asps
|
||||
|
||||
- `delete` — Deletes an ASP issued by a user.
|
||||
- `get` — Gets information about an ASP issued by a user.
|
||||
- `list` — Lists the ASPs issued by a user.
|
||||
|
||||
### channels
|
||||
|
||||
- `stop` — Stops watching resources through this channel.
|
||||
|
||||
### chromeosdevices
|
||||
|
||||
- `action` — Use [BatchChangeChromeOsDeviceStatus](https://developers.google.com/workspace/admin/directory/reference/rest/v1/customer.devices.chromeos/batchChangeStatus) instead. Takes an action that affects a Chr
|
||||
- `get` — Retrieves a Chrome OS device's properties.
|
||||
- `list` — Retrieves a paginated list of Chrome OS devices within an account.
|
||||
- `moveDevicesToOu` — Moves or inserts multiple Chrome OS devices to an organizational unit. You can move up to 50 devices at once.
|
||||
- `patch` — Updates a device's updatable properties, such as `annotatedUser`, `annotatedLocation`, `notes`, `orgUnitPath`, or `annotatedAssetId`. This method supports [patch semantics](https://developers.google.c
|
||||
- `update` — Updates a device's updatable properties, such as `annotatedUser`, `annotatedLocation`, `notes`, `orgUnitPath`, or `annotatedAssetId`.
|
||||
|
||||
### customer
|
||||
|
||||
- `devices` — Operations on the 'devices' resource
|
||||
|
||||
### customers
|
||||
|
||||
- `get` — Retrieves a customer.
|
||||
- `patch` — Patches a customer.
|
||||
- `update` — Updates a customer.
|
||||
- `chrome` — Operations on the 'chrome' resource
|
||||
|
||||
### domainAliases
|
||||
|
||||
- `delete` — Deletes a domain Alias of the customer.
|
||||
- `get` — Retrieves a domain alias of the customer.
|
||||
- `insert` — Inserts a domain alias of the customer.
|
||||
- `list` — Lists the domain aliases of the customer.
|
||||
|
||||
### domains
|
||||
|
||||
- `delete` — Deletes a domain of the customer.
|
||||
- `get` — Retrieves a domain of the customer.
|
||||
- `insert` — Inserts a domain of the customer.
|
||||
- `list` — Lists the domains of the customer.
|
||||
|
||||
### groups
|
||||
|
||||
- `delete` — Deletes a group.
|
||||
- `get` — Retrieves a group's properties.
|
||||
- `insert` — Creates a group.
|
||||
- `list` — Retrieves all groups of a domain or of a user given a userKey (paginated).
|
||||
- `patch` — Updates a group's properties. This method supports [patch semantics](https://developers.google.com/workspace/admin/directory/v1/guides/performance#patch).
|
||||
- `update` — Updates a group's properties.
|
||||
- `aliases` — Operations on the 'aliases' resource
|
||||
|
||||
### members
|
||||
|
||||
- `delete` — Removes a member from a group.
|
||||
- `get` — Retrieves a group member's properties.
|
||||
- `hasMember` — Checks whether the given user is a member of the group. Membership can be direct or nested, but if nested, the `memberKey` and `groupKey` must be entities in the same domain or an `Invalid input` erro
|
||||
- `insert` — Adds a user to the specified group.
|
||||
- `list` — Retrieves a paginated list of all members in a group. This method times out after 60 minutes. For more information, see [Troubleshoot error codes](https://developers.google.com/workspace/admin/directo
|
||||
- `patch` — Updates the membership properties of a user in the specified group. This method supports [patch semantics](https://developers.google.com/workspace/admin/directory/v1/guides/performance#patch).
|
||||
- `update` — Updates the membership of a user in the specified group.
|
||||
|
||||
### mobiledevices
|
||||
|
||||
- `action` — Takes an action that affects a mobile device. For example, remotely wiping a device.
|
||||
- `delete` — Removes a mobile device.
|
||||
- `get` — Retrieves a mobile device's properties.
|
||||
- `list` — Retrieves a paginated list of all user-owned mobile devices for an account. To retrieve a list that includes company-owned devices, use the Cloud Identity [Devices API](https://cloud.google.com/identi
|
||||
|
||||
### orgunits
|
||||
|
||||
- `delete` — Removes an organizational unit.
|
||||
- `get` — Retrieves an organizational unit.
|
||||
- `insert` — Adds an organizational unit.
|
||||
- `list` — Retrieves a list of all organizational units for an account.
|
||||
- `patch` — Updates an organizational unit. This method supports [patch semantics](https://developers.google.com/workspace/admin/directory/v1/guides/performance#patch)
|
||||
- `update` — Updates an organizational unit.
|
||||
|
||||
### privileges
|
||||
|
||||
- `list` — Retrieves a paginated list of all privileges for a customer.
|
||||
|
||||
### resources
|
||||
|
||||
- `buildings` — Operations on the 'buildings' resource
|
||||
- `calendars` — Operations on the 'calendars' resource
|
||||
- `features` — Operations on the 'features' resource
|
||||
|
||||
### roleAssignments
|
||||
|
||||
- `delete` — Deletes a role assignment.
|
||||
- `get` — Retrieves a role assignment.
|
||||
- `insert` — Creates a role assignment.
|
||||
- `list` — Retrieves a paginated list of all roleAssignments.
|
||||
|
||||
### roles
|
||||
|
||||
- `delete` — Deletes a role.
|
||||
- `get` — Retrieves a role.
|
||||
- `insert` — Creates a role.
|
||||
- `list` — Retrieves a paginated list of all the roles in a domain.
|
||||
- `patch` — Patches a role.
|
||||
- `update` — Updates a role.
|
||||
|
||||
### schemas
|
||||
|
||||
- `delete` — Deletes a schema.
|
||||
- `get` — Retrieves a schema.
|
||||
- `insert` — Creates a schema.
|
||||
- `list` — Retrieves all schemas for a customer.
|
||||
- `patch` — Patches a schema.
|
||||
- `update` — Updates a schema.
|
||||
|
||||
### tokens
|
||||
|
||||
- `delete` — Deletes all access tokens issued by a user for an application.
|
||||
- `get` — Gets information about an access token issued by a user.
|
||||
- `list` — Returns the set of tokens specified user has issued to 3rd party applications.
|
||||
|
||||
### twoStepVerification
|
||||
|
||||
- `turnOff` — Turns off 2-Step Verification for user.
|
||||
|
||||
### users
|
||||
|
||||
- `createGuest` — Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545?hl=en). This feature is currently in Alpha. Please reach out to support if you are
|
||||
- `delete` — Deletes a user.
|
||||
- `get` — Retrieves a user.
|
||||
- `insert` — Creates a user. Mutate calls immediately following user creation might sometimes fail as the user isn't fully created due to propagation delay in our backends. Check the error details for the "User cr
|
||||
- `list` — Retrieves a paginated list of either deleted users or all users in a domain.
|
||||
- `makeAdmin` — Makes a user a super administrator.
|
||||
- `patch` — Updates a user using patch semantics. The update method should be used instead, because it also supports patch semantics and has better performance. If you're mapping an external identity to a Google
|
||||
- `signOut` — Signs a user out of all web and device sessions and reset their sign-in cookies. User will have to sign in by authenticating again.
|
||||
- `undelete` — Undeletes a deleted user.
|
||||
- `update` — Updates a user. This method supports patch semantics, meaning that you only need to include the fields you wish to update. Fields that are not present in the request will be preserved, and fields set
|
||||
- `watch` — Watches for changes in users list.
|
||||
- `aliases` — Operations on the 'aliases' resource
|
||||
- `photos` — Operations on the 'photos' resource
|
||||
|
||||
### verificationCodes
|
||||
|
||||
- `generate` — Generates new backup verification codes for the user.
|
||||
- `invalidate` — Invalidates the current backup verification codes for the user.
|
||||
- `list` — Returns the current set of valid backup verification codes for the specified user.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws admin --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema admin.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gws-alertcenter
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage workspace security alerts via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws alertcenter --help"
|
||||
---
|
||||
|
||||
# alertcenter (v1beta1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws alertcenter <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### alerts
|
||||
|
||||
- `batchDelete` — Performs batch delete operation on alerts.
|
||||
- `batchUndelete` — Performs batch undelete operation on alerts.
|
||||
- `delete` — Marks the specified alert for deletion. An alert that has been marked for deletion is removed from Alert Center after 30 days. Marking an alert for deletion has no effect on an alert which has already
|
||||
- `get` — Gets the specified alert. Attempting to get a nonexistent alert returns `NOT_FOUND` error.
|
||||
- `getMetadata` — Returns the metadata of an alert. Attempting to get metadata for a non-existent alert returns `NOT_FOUND` error.
|
||||
- `list` — Lists the alerts.
|
||||
- `undelete` — Restores, or "undeletes", an alert that was marked for deletion within the past 30 days. Attempting to undelete an alert which was marked for deletion over 30 days ago (which has been removed from the
|
||||
- `feedback` — Operations on the 'feedback' resource
|
||||
|
||||
### v1beta1
|
||||
|
||||
- `getSettings` — Returns customer-level settings.
|
||||
- `updateSettings` — Updates the customer-level settings.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws alertcenter --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema alertcenter.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gws-apps-script-push
|
||||
version: 1.0.0
|
||||
description: "Upload local files to an Apps Script project"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws apps-script +push --help"
|
||||
---
|
||||
|
||||
# apps-script +push
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Upload local files to an Apps Script project
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws apps-script +push --script <ID>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--script` | ✓ | — | Script Project ID |
|
||||
| `--dir` | — | — | Directory containing script files (defaults to current dir) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: gws-apps-script
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage and execute apps script projects via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws apps-script --help"
|
||||
---
|
||||
|
||||
# apps-script (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws apps-script <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+push`](../gws-apps-script-push/SKILL.md) | Upload local files to an Apps Script project |
|
||||
|
||||
## API Resources
|
||||
|
||||
### processes
|
||||
|
||||
- `list` — List information about processes made by or on behalf of a user, such as process type and current status.
|
||||
- `listScriptProcesses` — List information about a script's executed processes, such as process type and current status.
|
||||
|
||||
### projects
|
||||
|
||||
- `create` — Creates a new, empty script project with no script files and a base manifest file.
|
||||
- `get` — Gets a script project's metadata.
|
||||
- `getContent` — Gets the content of the script project, including the code source and metadata for each script file.
|
||||
- `getMetrics` — Get metrics data for scripts, such as number of executions and active users.
|
||||
- `updateContent` — Updates the content of the specified script project. This content is stored as the HEAD version, and is used when the script is executed as a trigger, in the script editor, in add-on preview mode, or
|
||||
- `deployments` — Operations on the 'deployments' resource
|
||||
- `versions` — Operations on the 'versions' resource
|
||||
|
||||
### scripts
|
||||
|
||||
- `run` —
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws apps-script --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema apps-script.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gws-calendar-agenda
|
||||
version: 1.0.0
|
||||
description: "Show upcoming events across all calendars"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws calendar +agenda --help"
|
||||
---
|
||||
|
||||
# calendar +agenda
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Show upcoming events across all calendars
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws calendar +agenda
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--today` | — | — | Show today's events |
|
||||
| `--tomorrow` | — | — | Show tomorrow's events |
|
||||
| `--week` | — | — | Show this week's events |
|
||||
| `--days` | — | — | Number of days ahead to show |
|
||||
| `--calendar` | — | — | Filter to specific calendar name or ID |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws calendar +agenda
|
||||
gws calendar +agenda --today
|
||||
gws calendar +agenda --week --format table
|
||||
gws calendar +agenda --days 3 --calendar 'Work'
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Read-only — never modifies events.
|
||||
- Queries all calendars by default; use --calendar to filter.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-calendar](../gws-calendar/SKILL.md) — All manage calendars and events commands
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: gws-calendar-insert
|
||||
version: 1.0.0
|
||||
description: "create a new event"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws calendar +insert --help"
|
||||
---
|
||||
|
||||
# calendar +insert
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
create a new event
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws calendar +insert --summary <TEXT> --start <TIME> --end <TIME>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--calendar` | — | primary | Calendar ID (default: primary) |
|
||||
| `--summary` | ✓ | — | Event summary/title |
|
||||
| `--start` | ✓ | — | Start time (ISO 8601, e.g., 2024-01-01T10:00:00Z) |
|
||||
| `--end` | ✓ | — | End time (ISO 8601) |
|
||||
| `--location` | — | — | Event location |
|
||||
| `--description` | — | — | Event description/body |
|
||||
| `--attendee` | — | — | Attendee email (can be used multiple times) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
|
||||
- For recurring events or conference links, use the raw API instead.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-calendar](../gws-calendar/SKILL.md) — All manage calendars and events commands
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: gws-calendar
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage calendars and events via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws calendar --help"
|
||||
---
|
||||
|
||||
# calendar (v3)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws calendar <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+insert`](../gws-calendar-insert/SKILL.md) | create a new event |
|
||||
| [`+agenda`](../gws-calendar-agenda/SKILL.md) | Show upcoming events across all calendars |
|
||||
|
||||
## API Resources
|
||||
|
||||
### acl
|
||||
|
||||
- `delete` — Deletes an access control rule.
|
||||
- `get` — Returns an access control rule.
|
||||
- `insert` — Creates an access control rule.
|
||||
- `list` — Returns the rules in the access control list for the calendar.
|
||||
- `patch` — Updates an access control rule. This method supports patch semantics.
|
||||
- `update` — Updates an access control rule.
|
||||
- `watch` — Watch for changes to ACL resources.
|
||||
|
||||
### calendarList
|
||||
|
||||
- `delete` — Removes a calendar from the user's calendar list.
|
||||
- `get` — Returns a calendar from the user's calendar list.
|
||||
- `insert` — Inserts an existing calendar into the user's calendar list.
|
||||
- `list` — Returns the calendars on the user's calendar list.
|
||||
- `patch` — Updates an existing calendar on the user's calendar list. This method supports patch semantics.
|
||||
- `update` — Updates an existing calendar on the user's calendar list.
|
||||
- `watch` — Watch for changes to CalendarList resources.
|
||||
|
||||
### calendars
|
||||
|
||||
- `clear` — Clears a primary calendar. This operation deletes all events associated with the primary calendar of an account.
|
||||
- `delete` — Deletes a secondary calendar. Use calendars.clear for clearing all events on primary calendars.
|
||||
- `get` — Returns metadata for a calendar.
|
||||
- `insert` — Creates a secondary calendar.
|
||||
The authenticated user for the request is made the data owner of the new calendar.
|
||||
|
||||
Note: We recommend to authenticate as the intended data owner of the calendar. You can
|
||||
- `patch` — Updates metadata for a calendar. This method supports patch semantics.
|
||||
- `update` — Updates metadata for a calendar.
|
||||
|
||||
### channels
|
||||
|
||||
- `stop` — Stop watching resources through this channel
|
||||
|
||||
### colors
|
||||
|
||||
- `get` — Returns the color definitions for calendars and events.
|
||||
|
||||
### events
|
||||
|
||||
- `delete` — Deletes an event.
|
||||
- `get` — Returns an event based on its Google Calendar ID. To retrieve an event using its iCalendar ID, call the events.list method using the iCalUID parameter.
|
||||
- `import` — Imports an event. This operation is used to add a private copy of an existing event to a calendar. Only events with an eventType of default may be imported.
|
||||
Deprecated behavior: If a non-default event
|
||||
- `insert` — Creates an event.
|
||||
- `instances` — Returns instances of the specified recurring event.
|
||||
- `list` — Returns events on the specified calendar.
|
||||
- `move` — Moves an event to another calendar, i.e. changes an event's organizer. Note that only default events can be moved; birthday, focusTime, fromGmail, outOfOffice and workingLocation events cannot be move
|
||||
- `patch` — Updates an event. This method supports patch semantics.
|
||||
- `quickAdd` — Creates an event based on a simple text string.
|
||||
- `update` — Updates an event.
|
||||
- `watch` — Watch for changes to Events resources.
|
||||
|
||||
### freebusy
|
||||
|
||||
- `query` — Returns free/busy information for a set of calendars.
|
||||
|
||||
### settings
|
||||
|
||||
- `get` — Returns a single user setting.
|
||||
- `list` — Returns all user settings for the authenticated user.
|
||||
- `watch` — Watch for changes to Settings resources.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws calendar --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema calendar.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: gws-chat-send
|
||||
version: 1.0.0
|
||||
description: "Send a message to a space"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws chat +send --help"
|
||||
---
|
||||
|
||||
# chat +send
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Send a message to a space
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws chat +send --space <NAME> --text <TEXT>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--space` | ✓ | — | Space name (e.g. spaces/AAAA...) |
|
||||
| `--text` | ✓ | — | Message text (plain text) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-chat](../gws-chat/SKILL.md) — All manage chat spaces and messages commands
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: gws-chat
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage chat spaces and messages via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws chat --help"
|
||||
---
|
||||
|
||||
# chat (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws chat <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+send`](../gws-chat-send/SKILL.md) | Send a message to a space |
|
||||
|
||||
## API Resources
|
||||
|
||||
### customEmojis
|
||||
|
||||
- `create` — Creates a custom emoji. Custom emojis are only available for Google Workspace accounts, and the administrator must turn custom emojis on for the organization. For more information, see [Learn about cu
|
||||
- `delete` — Deletes a custom emoji. By default, users can only delete custom emoji they created. [Emoji managers](https://support.google.com/a/answer/12850085) assigned by the administrator can delete any custom
|
||||
- `get` — Returns details about a custom emoji. Custom emojis are only available for Google Workspace accounts, and the administrator must turn custom emojis on for the organization. For more information, see [
|
||||
- `list` — Lists custom emojis visible to the authenticated user. Custom emojis are only available for Google Workspace accounts, and the administrator must turn custom emojis on for the organization. For more i
|
||||
|
||||
### media
|
||||
|
||||
- `download` — Downloads media. Download is supported on the URI `/v1/media/{+name}?alt=media`.
|
||||
- `upload` — Uploads an attachment. For an example, see [Upload media as a file attachment](https://developers.google.com/workspace/chat/upload-media-attachments). Requires user [authentication](https://developers
|
||||
|
||||
### spaces
|
||||
|
||||
- `completeImport` — Completes the [import process](https://developers.google.com/workspace/chat/import-data) for the specified space and makes it visible to users. Requires [user authentication](https://developers.google
|
||||
- `create` — Creates a space. Can be used to create a named space, or a group chat in `Import mode`. For an example, see [Create a space](https://developers.google.com/workspace/chat/create-spaces). Supports the f
|
||||
- `delete` — Deletes a named space. Always performs a cascading delete, which means that the space's child resources—like messages posted in the space and memberships in the space—are also deleted. For an example,
|
||||
- `findDirectMessage` — Returns the existing direct message with the specified user. If no direct message space is found, returns a `404 NOT_FOUND` error. For an example, see [Find a direct message](/chat/api/guides/v1/space
|
||||
- `get` — Returns details about a space. For an example, see [Get details about a space](https://developers.google.com/workspace/chat/get-spaces). Supports the following types of [authentication](https://develo
|
||||
- `list` — Lists spaces the caller is a member of. Group chats and DMs aren't listed until the first message is sent. For an example, see [List spaces](https://developers.google.com/workspace/chat/list-spaces).
|
||||
- `patch` — Updates a space. For an example, see [Update a space](https://developers.google.com/workspace/chat/update-spaces). If you're updating the `displayName` field and receive the error message `ALREADY_EXI
|
||||
- `search` — Returns a list of spaces in a Google Workspace organization based on an administrator's search. In the request, set `use_admin_access` to `true`. For an example, see [Search for and manage spaces](htt
|
||||
- `setup` — Creates a space and adds specified users to it. The calling user is automatically added to the space, and shouldn't be specified as a membership in the request. For an example, see [Set up a space wit
|
||||
- `members` — Operations on the 'members' resource
|
||||
- `messages` — Operations on the 'messages' resource
|
||||
- `spaceEvents` — Operations on the 'spaceEvents' resource
|
||||
|
||||
### users
|
||||
|
||||
- `spaces` — Operations on the 'spaces' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws chat --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema chat.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: gws-classroom
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage classes, rosters, and coursework via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws classroom --help"
|
||||
---
|
||||
|
||||
# classroom (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws classroom <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### courses
|
||||
|
||||
- `create` — Creates a course. The user specified in `ownerId` is the owner of the created course and added as a teacher. A non-admin requesting user can only create a course with themselves as the owner. Domain a
|
||||
- `delete` — Deletes a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to delete the requested course or for access errors. * `NOT_FOUND` if no
|
||||
- `get` — Returns a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to access the requested course or for access errors. * `NOT_FOUND` if no
|
||||
- `getGradingPeriodSettings` — Returns the grading period settings in a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user isn't permitted to access the grading period settings in th
|
||||
- `list` — Returns a list of courses that the requesting user is permitted to view, restricted to those that match the request. Returned courses are ordered by creation time, with the most recently created comin
|
||||
- `patch` — Updates one or more fields in a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to modify the requested course or for access errors
|
||||
- `update` — Updates a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to modify the requested course or for access errors. * `NOT_FOUND` if no
|
||||
- `updateGradingPeriodSettings` — Updates grading period settings of a course. Individual grading periods can be added, removed, or modified using this method. The requesting user and course owner must be eligible to modify Grading Pe
|
||||
- `aliases` — Operations on the 'aliases' resource
|
||||
- `announcements` — Operations on the 'announcements' resource
|
||||
- `courseWork` — Operations on the 'courseWork' resource
|
||||
- `courseWorkMaterials` — Operations on the 'courseWorkMaterials' resource
|
||||
- `posts` — Operations on the 'posts' resource
|
||||
- `studentGroups` — Operations on the 'studentGroups' resource
|
||||
- `students` — Operations on the 'students' resource
|
||||
- `teachers` — Operations on the 'teachers' resource
|
||||
- `topics` — Operations on the 'topics' resource
|
||||
|
||||
### invitations
|
||||
|
||||
- `accept` — Accepts an invitation, removing it and adding the invited user to the teachers or students (as appropriate) of the specified course. Only the invited user may accept an invitation. This method returns
|
||||
- `create` — Creates an invitation. Only one invitation for a user and course may exist at a time. Delete and re-create an invitation to make changes. This method returns the following error codes: * `PERMISSION_D
|
||||
- `delete` — Deletes an invitation. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to delete the requested invitation or for access errors. * `NOT_FOUN
|
||||
- `get` — Returns an invitation. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to view the requested invitation or for access errors. * `NOT_FOUND`
|
||||
- `list` — Returns a list of invitations that the requesting user is permitted to view, restricted to those that match the list request. *Note:* At least one of `user_id` or `course_id` must be supplied. Both fi
|
||||
|
||||
### registrations
|
||||
|
||||
- `create` — Creates a `Registration`, causing Classroom to start sending notifications from the provided `feed` to the destination provided in `cloudPubSubTopic`. Returns the created `Registration`. Currently, th
|
||||
- `delete` — Deletes a `Registration`, causing Classroom to stop sending notifications for that `Registration`.
|
||||
|
||||
### userProfiles
|
||||
|
||||
- `get` — Returns a user profile. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to access this user profile, if no profile exists with the requeste
|
||||
- `guardianInvitations` — Operations on the 'guardianInvitations' resource
|
||||
- `guardians` — Operations on the 'guardians' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws classroom --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema classroom.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: gws-cloudidentity
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage identity groups and memberships via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws cloudidentity --help"
|
||||
---
|
||||
|
||||
# cloudidentity (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws cloudidentity <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### customers
|
||||
|
||||
- `userinvitations` — Operations on the 'userinvitations' resource
|
||||
|
||||
### devices
|
||||
|
||||
- `cancelWipe` — Cancels an unfinished device wipe. This operation can be used to cancel device wipe in the gap between the wipe operation returning success and the device being wiped. This operation is possible when
|
||||
- `create` — Creates a device. Only company-owned device may be created. **Note**: This method is available only to customers who have one of the following SKUs: Enterprise Standard, Enterprise Plus, Enterprise fo
|
||||
- `delete` — Deletes the specified device.
|
||||
- `get` — Retrieves the specified device.
|
||||
- `list` — Lists/Searches devices.
|
||||
- `wipe` — Wipes all data on the specified device.
|
||||
- `deviceUsers` — Operations on the 'deviceUsers' resource
|
||||
|
||||
### groups
|
||||
|
||||
- `create` — Creates a Group.
|
||||
- `delete` — Deletes a `Group`.
|
||||
- `get` — Retrieves a `Group`.
|
||||
- `getSecuritySettings` — Get Security Settings
|
||||
- `list` — Lists the `Group` resources under a customer or namespace.
|
||||
- `lookup` — Looks up the [resource name](https://cloud.google.com/apis/design/resource_names) of a `Group` by its `EntityKey`.
|
||||
- `patch` — Updates a `Group`.
|
||||
- `search` — Searches for `Group` resources matching a specified query.
|
||||
- `updateSecuritySettings` — Update Security Settings
|
||||
- `memberships` — Operations on the 'memberships' resource
|
||||
|
||||
### inboundOidcSsoProfiles
|
||||
|
||||
- `create` — Creates an InboundOidcSsoProfile for a customer. When the target customer has enabled [Multi-party approval for sensitive actions](https://support.google.com/a/answer/13790448), the `Operation` in the
|
||||
- `delete` — Deletes an InboundOidcSsoProfile.
|
||||
- `get` — Gets an InboundOidcSsoProfile.
|
||||
- `list` — Lists InboundOidcSsoProfile objects for a Google enterprise customer.
|
||||
- `patch` — Updates an InboundOidcSsoProfile. When the target customer has enabled [Multi-party approval for sensitive actions](https://support.google.com/a/answer/13790448), the `Operation` in the response will
|
||||
|
||||
### inboundSamlSsoProfiles
|
||||
|
||||
- `create` — Creates an InboundSamlSsoProfile for a customer. When the target customer has enabled [Multi-party approval for sensitive actions](https://support.google.com/a/answer/13790448), the `Operation` in the
|
||||
- `delete` — Deletes an InboundSamlSsoProfile.
|
||||
- `get` — Gets an InboundSamlSsoProfile.
|
||||
- `list` — Lists InboundSamlSsoProfiles for a customer.
|
||||
- `patch` — Updates an InboundSamlSsoProfile. When the target customer has enabled [Multi-party approval for sensitive actions](https://support.google.com/a/answer/13790448), the `Operation` in the response will
|
||||
- `idpCredentials` — Operations on the 'idpCredentials' resource
|
||||
|
||||
### inboundSsoAssignments
|
||||
|
||||
- `create` — Creates an InboundSsoAssignment for users and devices in a `Customer` under a given `Group` or `OrgUnit`.
|
||||
- `delete` — Deletes an InboundSsoAssignment. To disable SSO, Create (or Update) an assignment that has `sso_mode` == `SSO_OFF`.
|
||||
- `get` — Gets an InboundSsoAssignment.
|
||||
- `list` — Lists the InboundSsoAssignments for a `Customer`.
|
||||
- `patch` — Updates an InboundSsoAssignment. The body of this request is the `inbound_sso_assignment` field and the `update_mask` is relative to that. For example: a PATCH to `/v1/inboundSsoAssignments/0abcdefg12
|
||||
|
||||
### policies
|
||||
|
||||
- `get` — Get a policy.
|
||||
- `list` — List policies.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws cloudidentity --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema cloudidentity.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: gws-docs-write
|
||||
version: 1.0.0
|
||||
description: "Append text to a document"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws docs +write --help"
|
||||
---
|
||||
|
||||
# docs +write
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Append text to a document
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws docs +write --document <ID> --text <TEXT>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--document` | ✓ | — | Document ID |
|
||||
| `--text` | ✓ | — | Text to append (plain text) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-docs](../gws-docs/SKILL.md) — All read and write google docs commands
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gws-docs
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to read and write google docs via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws docs --help"
|
||||
---
|
||||
|
||||
# docs (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws docs <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+write`](../gws-docs-write/SKILL.md) | Append text to a document |
|
||||
|
||||
## API Resources
|
||||
|
||||
### documents
|
||||
|
||||
- `batchUpdate` — Applies one or more updates to the document. Each request is validated before being applied. If any request is not valid, then the entire request will fail and nothing will be applied. Some requests h
|
||||
- `create` — Creates a blank document using the title given in the request. Other fields in the request, including any provided content, are ignored. Returns the created document.
|
||||
- `get` — Gets the latest version of the specified document.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws docs --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema docs.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gws-drive-upload
|
||||
version: 1.0.0
|
||||
description: "Upload a file with automatic metadata"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws drive +upload --help"
|
||||
---
|
||||
|
||||
# drive +upload
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Upload a file with automatic metadata
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws drive +upload <file>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `<file>` | ✓ | — | Path to file to upload |
|
||||
| `--parent` | — | — | Parent folder ID |
|
||||
| `--name` | — | — | Target filename (defaults to source filename) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-drive](../gws-drive/SKILL.md) — All manage files, folders, and shared drives commands
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: gws-drive
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage files, folders, and shared drives via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws drive --help"
|
||||
---
|
||||
|
||||
# drive (v3)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws drive <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+upload`](../gws-drive-upload/SKILL.md) | Upload a file with automatic metadata |
|
||||
|
||||
## API Resources
|
||||
|
||||
### about
|
||||
|
||||
- `get` — Gets information about the user, the user's Drive, and system capabilities. For more information, see [Return user info](https://developers.google.com/workspace/drive/api/guides/user-info). Required:
|
||||
|
||||
### accessproposals
|
||||
|
||||
- `get` — Retrieves an access proposal by ID. For more information, see [Manage pending access proposals](https://developers.google.com/workspace/drive/api/guides/pending-access).
|
||||
- `list` — List the access proposals on a file. For more information, see [Manage pending access proposals](https://developers.google.com/workspace/drive/api/guides/pending-access). Note: Only approvers are able
|
||||
- `resolve` — Approves or denies an access proposal. For more information, see [Manage pending access proposals](https://developers.google.com/workspace/drive/api/guides/pending-access).
|
||||
|
||||
### approvals
|
||||
|
||||
- `get` — Gets an Approval by ID.
|
||||
- `list` — Lists the Approvals on a file.
|
||||
|
||||
### apps
|
||||
|
||||
- `get` — Gets a specific app. For more information, see [Return user info](https://developers.google.com/workspace/drive/api/guides/user-info).
|
||||
- `list` — Lists a user's installed apps. For more information, see [Return user info](https://developers.google.com/workspace/drive/api/guides/user-info).
|
||||
|
||||
### changes
|
||||
|
||||
- `getStartPageToken` — Gets the starting pageToken for listing future changes. For more information, see [Retrieve changes](https://developers.google.com/workspace/drive/api/guides/manage-changes).
|
||||
- `list` — Lists the changes for a user or shared drive. For more information, see [Retrieve changes](https://developers.google.com/workspace/drive/api/guides/manage-changes).
|
||||
- `watch` — Subscribes to changes for a user. For more information, see [Notifications for resource changes](https://developers.google.com/workspace/drive/api/guides/push).
|
||||
|
||||
### channels
|
||||
|
||||
- `stop` — Stops watching resources through this channel. For more information, see [Notifications for resource changes](https://developers.google.com/workspace/drive/api/guides/push).
|
||||
|
||||
### comments
|
||||
|
||||
- `create` — Creates a comment on a file. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments). Required: The `fields` parameter must be
|
||||
- `delete` — Deletes a comment. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
- `get` — Gets a comment by ID. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments). Required: The `fields` parameter must be set. T
|
||||
- `list` — Lists a file's comments. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments). Required: The `fields` parameter must be set
|
||||
- `update` — Updates a comment with patch semantics. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments). Required: The `fields` parame
|
||||
|
||||
### drives
|
||||
|
||||
- `create` — Creates a shared drive. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
|
||||
- `delete` — Permanently deletes a shared drive for which the user is an `organizer`. The shared drive cannot contain any untrashed items. For more information, see [Manage shared drives](https://developers.google
|
||||
- `get` — Gets a shared drive's metadata by ID. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
|
||||
- `hide` — Hides a shared drive from the default view. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
|
||||
- `list` — Lists the user's shared drives. This method accepts the `q` parameter, which is a search query combining one or more search terms. For more information, see the [Search for shared drives](/workspace/
|
||||
- `unhide` — Restores a shared drive to the default view. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
|
||||
- `update` — Updates the metadata for a shared drive. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
|
||||
|
||||
### files
|
||||
|
||||
- `copy` — Creates a copy of a file and applies any requested updates with patch semantics. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-fil
|
||||
- `create` — Creates a file. For more information, see [Create and manage files](/workspace/drive/api/guides/create-file). This method supports an */upload* URI and accepts uploaded media with the following chara
|
||||
- `delete` — Permanently deletes a file owned by the user without moving it to the trash. For more information, see [Trash or delete files and folders](https://developers.google.com/workspace/drive/api/guides/dele
|
||||
- `download` — Downloads the content of a file. For more information, see [Download and export files](https://developers.google.com/workspace/drive/api/guides/manage-downloads). Operations are valid for 24 hours fro
|
||||
- `emptyTrash` — Permanently deletes all of the user's trashed files. For more information, see [Trash or delete files and folders](https://developers.google.com/workspace/drive/api/guides/delete).
|
||||
- `export` — Exports a Google Workspace document to the requested MIME type and returns exported byte content. For more information, see [Download and export files](https://developers.google.com/workspace/drive/ap
|
||||
- `generateIds` — Generates a set of file IDs which can be provided in create or copy requests. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file).
|
||||
- `get` — Gets a file's metadata or content by ID. For more information, see [Search for files and folders](/workspace/drive/api/guides/search-files). If you provide the URL parameter `alt=media`, then the res
|
||||
- `list` — Lists the user's files. For more information, see [Search for files and folders](/workspace/drive/api/guides/search-files). This method accepts the `q` parameter, which is a search query combining on
|
||||
- `listLabels` — Lists the labels on a file. For more information, see [List labels on a file](https://developers.google.com/workspace/drive/api/guides/list-labels).
|
||||
- `modifyLabels` — Modifies the set of labels applied to a file. For more information, see [Set a label field on a file](https://developers.google.com/workspace/drive/api/guides/set-label). Returns a list of the labels
|
||||
- `update` — Updates a file's metadata, content, or both. When calling this method, only populate fields in the request that you want to modify. When updating fields, some fields might be changed automatically, s
|
||||
- `watch` — Subscribes to changes to a file. For more information, see [Notifications for resource changes](https://developers.google.com/workspace/drive/api/guides/push).
|
||||
|
||||
### operations
|
||||
|
||||
- `get` — Gets the latest state of a long-running operation. Clients can use this method to poll the operation result at intervals as recommended by the API service.
|
||||
|
||||
### permissions
|
||||
|
||||
- `create` — Creates a permission for a file or shared drive. For more information, see [Share files, folders, and drives](https://developers.google.com/workspace/drive/api/guides/manage-sharing). **Warning:** Con
|
||||
- `delete` — Deletes a permission. For more information, see [Share files, folders, and drives](https://developers.google.com/workspace/drive/api/guides/manage-sharing). **Warning:** Concurrent permissions operati
|
||||
- `get` — Gets a permission by ID. For more information, see [Share files, folders, and drives](https://developers.google.com/workspace/drive/api/guides/manage-sharing).
|
||||
- `list` — Lists a file's or shared drive's permissions. For more information, see [Share files, folders, and drives](https://developers.google.com/workspace/drive/api/guides/manage-sharing).
|
||||
- `update` — Updates a permission with patch semantics. For more information, see [Share files, folders, and drives](https://developers.google.com/workspace/drive/api/guides/manage-sharing). **Warning:** Concurren
|
||||
|
||||
### replies
|
||||
|
||||
- `create` — Creates a reply to a comment. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
- `delete` — Deletes a reply. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
- `get` — Gets a reply by ID. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
- `list` — Lists a comment's replies. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
- `update` — Updates a reply with patch semantics. For more information, see [Manage comments and replies](https://developers.google.com/workspace/drive/api/guides/manage-comments).
|
||||
|
||||
### revisions
|
||||
|
||||
- `delete` — Permanently deletes a file version. You can only delete revisions for files with binary content in Google Drive, like images or videos. Revisions for other files, like Google Docs or Sheets, and the l
|
||||
- `get` — Gets a revision's metadata or content by ID. For more information, see [Manage file revisions](https://developers.google.com/workspace/drive/api/guides/manage-revisions).
|
||||
- `list` — Lists a file's revisions. For more information, see [Manage file revisions](https://developers.google.com/workspace/drive/api/guides/manage-revisions). **Important:** The list of revisions returned by
|
||||
- `update` — Updates a revision with patch semantics. For more information, see [Manage file revisions](https://developers.google.com/workspace/drive/api/guides/manage-revisions).
|
||||
|
||||
### teamdrives
|
||||
|
||||
- `create` — Deprecated: Use `drives.create` instead.
|
||||
- `delete` — Deprecated: Use `drives.delete` instead.
|
||||
- `get` — Deprecated: Use `drives.get` instead.
|
||||
- `list` — Deprecated: Use `drives.list` instead.
|
||||
- `update` — Deprecated: Use `drives.update` instead.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws drive --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema drive.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gws-events-renew
|
||||
version: 1.0.0
|
||||
description: "Renew/reactivate Workspace Events subscriptions"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws events +renew --help"
|
||||
---
|
||||
|
||||
# events +renew
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Renew/reactivate Workspace Events subscriptions
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws events +renew
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--name` | — | — | Subscription name to reactivate (e.g., subscriptions/SUB_ID) |
|
||||
| `--all` | — | — | Renew all subscriptions expiring within --within window |
|
||||
| `--within` | — | 1h | Time window for --all (e.g., 1h, 30m, 2d) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-events](../gws-events/SKILL.md) — All subscribe to google workspace events commands
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: gws-events-subscribe
|
||||
version: 1.0.0
|
||||
description: "Subscribe to Workspace events and stream them as NDJSON"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws events +subscribe --help"
|
||||
---
|
||||
|
||||
# events +subscribe
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Subscribe to Workspace events and stream them as NDJSON
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws events +subscribe
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--target` | — | — | Workspace resource URI (e.g., //chat.googleapis.com/spaces/SPACE_ID) |
|
||||
| `--event-types` | — | — | Comma-separated CloudEvents types to subscribe to |
|
||||
| `--project` | — | — | GCP project ID for Pub/Sub resources |
|
||||
| `--subscription` | — | — | Existing Pub/Sub subscription name (skip setup) |
|
||||
| `--max-messages` | — | 10 | Max messages per pull batch (default: 10) |
|
||||
| `--poll-interval` | — | 5 | Seconds between pulls (default: 5) |
|
||||
| `--once` | — | — | Pull once and exit |
|
||||
| `--cleanup` | — | — | Delete created Pub/Sub resources on exit |
|
||||
| `--no-ack` | — | — | Don't auto-acknowledge messages |
|
||||
| `--output-dir` | — | — | Write each event to a separate JSON file in this directory |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-events](../gws-events/SKILL.md) — All subscribe to google workspace events commands
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: gws-events
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to subscribe to google workspace events via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws events --help"
|
||||
---
|
||||
|
||||
# events (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws events <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+subscribe`](../gws-events-subscribe/SKILL.md) | Subscribe to Workspace events and stream them as NDJSON |
|
||||
| [`+renew`](../gws-events-renew/SKILL.md) | Renew/reactivate Workspace Events subscriptions |
|
||||
|
||||
## API Resources
|
||||
|
||||
### message
|
||||
|
||||
- `stream` — SendStreamingMessage is a streaming call that will return a stream of task update events until the Task is in an interrupted or terminal state.
|
||||
|
||||
### operations
|
||||
|
||||
- `get` — Gets the latest state of a long-running operation. Clients can use this method to poll the operation result at intervals as recommended by the API service.
|
||||
|
||||
### subscriptions
|
||||
|
||||
- `create` — Creates a Google Workspace subscription. To learn how to use this method, see [Create a Google Workspace subscription](https://developers.google.com/workspace/events/guides/create-subscription). For a
|
||||
- `delete` — Deletes a Google Workspace subscription. To learn how to use this method, see [Delete a Google Workspace subscription](https://developers.google.com/workspace/events/guides/delete-subscription).
|
||||
- `get` — Gets details about a Google Workspace subscription. To learn how to use this method, see [Get details about a Google Workspace subscription](https://developers.google.com/workspace/events/guides/get-s
|
||||
- `list` — Lists Google Workspace subscriptions. To learn how to use this method, see [List Google Workspace subscriptions](https://developers.google.com/workspace/events/guides/list-subscriptions).
|
||||
- `patch` — Updates or renews a Google Workspace subscription. To learn how to use this method, see [Update or renew a Google Workspace subscription](https://developers.google.com/workspace/events/guides/update-s
|
||||
- `reactivate` — Reactivates a suspended Google Workspace subscription. This method resets your subscription's `State` field to `ACTIVE`. Before you use this method, you must fix the error that suspended the subscript
|
||||
|
||||
### tasks
|
||||
|
||||
- `cancel` — Cancel a task from the agent. If supported one should expect no more task updates for the task.
|
||||
- `get` — Get the current state of a task from the agent.
|
||||
- `subscribe` — TaskSubscription is a streaming call that will return a stream of task update events. This attaches the stream to an existing in process task. If the task is complete the stream will return the comple
|
||||
- `pushNotificationConfigs` — Operations on the 'pushNotificationConfigs' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws events --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema events.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: gws-forms
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to read and write google forms via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws forms --help"
|
||||
---
|
||||
|
||||
# forms (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws forms <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### forms
|
||||
|
||||
- `batchUpdate` — Change the form with a batch of updates.
|
||||
- `create` — Create a new form using the title given in the provided form message in the request. *Important:* Only the form.info.title and form.info.document_title fields are copied to the new form. All other fie
|
||||
- `get` — Get a form.
|
||||
- `setPublishSettings` — Updates the publish settings of a form. Legacy forms aren't supported because they don't have the `publish_settings` field.
|
||||
- `responses` — Operations on the 'responses' resource
|
||||
- `watches` — Operations on the 'watches' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws forms --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema forms.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gws-gmail-send
|
||||
version: 1.0.0
|
||||
description: "Send an email"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +send --help"
|
||||
---
|
||||
|
||||
# gmail +send
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Send an email
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +send --to <EMAIL> --subject <SUBJECT> --body <TEXT>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--to` | ✓ | — | Recipient email address |
|
||||
| `--subject` | ✓ | — | Email subject |
|
||||
| `--body` | ✓ | — | Email body (plain text) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!'
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Handles RFC 2822 formatting and base64 encoding automatically.
|
||||
- For HTML bodies, attachments, or CC/BCC, use the raw API instead:
|
||||
- gws gmail users messages send --json '...'
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: gws-gmail-triage
|
||||
version: 1.0.0
|
||||
description: "Show unread inbox summary (sender, subject, date)"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +triage --help"
|
||||
---
|
||||
|
||||
# gmail +triage
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Show unread inbox summary (sender, subject, date)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +triage
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--max` | — | 20 | Maximum messages to show (default: 20) |
|
||||
| `--query` | — | — | Gmail search query (default: is:unread) |
|
||||
| `--labels` | — | — | Include label names in output |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +triage
|
||||
gws gmail +triage --max 5 --query 'from:boss'
|
||||
gws gmail +triage --format json | jq '.[].subject'
|
||||
gws gmail +triage --labels
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Read-only — never modifies your mailbox.
|
||||
- Defaults to table output format.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: gws-gmail-watch
|
||||
version: 1.0.0
|
||||
description: "Watch for new emails and stream them as NDJSON"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +watch --help"
|
||||
---
|
||||
|
||||
# gmail +watch
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Watch for new emails and stream them as NDJSON
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +watch
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--project` | — | — | GCP project ID for Pub/Sub resources |
|
||||
| `--subscription` | — | — | Existing Pub/Sub subscription name (skip setup) |
|
||||
| `--topic` | — | — | Existing Pub/Sub topic with Gmail push permission already granted |
|
||||
| `--label-ids` | — | — | Comma-separated Gmail label IDs to filter (e.g., INBOX,UNREAD) |
|
||||
| `--max-messages` | — | 10 | Max messages per pull batch |
|
||||
| `--poll-interval` | — | 5 | Seconds between pulls |
|
||||
| `--msg-format` | — | full | Gmail message format: full, metadata, minimal, raw |
|
||||
| `--once` | — | — | Pull once and exit |
|
||||
| `--cleanup` | — | — | Delete created Pub/Sub resources on exit |
|
||||
| `--output-dir` | — | — | Write each message to a separate JSON file in this directory |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +watch --project my-gcp-project
|
||||
gws gmail +watch --project my-project --label-ids INBOX --once
|
||||
gws gmail +watch --subscription projects/p/subscriptions/my-sub
|
||||
gws gmail +watch --project my-project --cleanup --output-dir ./emails
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Gmail watch expires after 7 days — re-run to renew.
|
||||
- Without --cleanup, Pub/Sub resources persist for reconnection.
|
||||
- Press Ctrl-C to stop gracefully.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: gws-gmail
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to send, read, and manage email via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail --help"
|
||||
---
|
||||
|
||||
# gmail (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws gmail <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+send`](../gws-gmail-send/SKILL.md) | Send an email |
|
||||
| [`+triage`](../gws-gmail-triage/SKILL.md) | Show unread inbox summary (sender, subject, date) |
|
||||
| [`+watch`](../gws-gmail-watch/SKILL.md) | Watch for new emails and stream them as NDJSON |
|
||||
|
||||
## API Resources
|
||||
|
||||
### users
|
||||
|
||||
- `getProfile` — Gets the current user's Gmail profile.
|
||||
- `stop` — Stop receiving push notifications for the given user mailbox.
|
||||
- `watch` — Set up or update a push notification watch on the given user mailbox.
|
||||
- `drafts` — Operations on the 'drafts' resource
|
||||
- `history` — Operations on the 'history' resource
|
||||
- `labels` — Operations on the 'labels' resource
|
||||
- `messages` — Operations on the 'messages' resource
|
||||
- `settings` — Operations on the 'settings' resource
|
||||
- `threads` — Operations on the 'threads' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws gmail --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema gmail.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: gws-groupssettings
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage google groups settings via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws groupssettings --help"
|
||||
---
|
||||
|
||||
# groupssettings (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws groupssettings <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### groups
|
||||
|
||||
- `get` — Gets one resource by id.
|
||||
- `patch` — Updates an existing resource. This method supports patch semantics.
|
||||
- `update` — Updates an existing resource.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws groupssettings --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema groupssettings.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gws-keep
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage google keep notes via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws keep --help"
|
||||
---
|
||||
|
||||
# keep (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws keep <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### media
|
||||
|
||||
- `download` — Gets an attachment. To download attachment media via REST requires the alt=media query parameter. Returns a 400 bad request error if attachment media is not available in the requested MIME type.
|
||||
|
||||
### notes
|
||||
|
||||
- `create` — Creates a new note.
|
||||
- `delete` — Deletes a note. Caller must have the `OWNER` role on the note to delete. Deleting a note removes the resource immediately and cannot be undone. Any collaborators will lose access to the note.
|
||||
- `get` — Gets a note.
|
||||
- `list` — Lists notes. Every list call returns a page of results with `page_size` as the upper bound of returned items. A `page_size` of zero allows the server to choose the upper bound. The ListNotesResponse c
|
||||
- `permissions` — Operations on the 'permissions' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws keep --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema keep.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: gws-licensing
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage product licenses via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws licensing --help"
|
||||
---
|
||||
|
||||
# licensing (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws licensing <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### licenseAssignments
|
||||
|
||||
- `delete` — Revoke a license.
|
||||
- `get` — Get a specific user's license by product SKU.
|
||||
- `insert` — Assign a license.
|
||||
- `listForProduct` — List all users assigned licenses for a specific product SKU.
|
||||
- `listForProductAndSku` — List all users assigned licenses for a specific product SKU.
|
||||
- `patch` — Reassign a user's product SKU with a different SKU in the same product. This method supports patch semantics.
|
||||
- `update` — Reassign a user's product SKU with a different SKU in the same product.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws licensing --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema licensing.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gws-meet
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage google meet conferences via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws meet --help"
|
||||
---
|
||||
|
||||
# meet (v2)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws meet <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### conferenceRecords
|
||||
|
||||
- `get` — Gets a conference record by conference ID.
|
||||
- `list` — Lists the conference records. By default, ordered by start time and in descending order.
|
||||
- `participants` — Operations on the 'participants' resource
|
||||
- `recordings` — Operations on the 'recordings' resource
|
||||
- `transcripts` — Operations on the 'transcripts' resource
|
||||
|
||||
### spaces
|
||||
|
||||
- `create` — Creates a space.
|
||||
- `endActiveConference` — Ends an active conference (if there's one). For an example, see [End active conference](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#end-active-conference).
|
||||
- `get` — Gets details about a meeting space. For an example, see [Get a meeting space](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#get-meeting-space).
|
||||
- `patch` — Updates details about a meeting space. For an example, see [Update a meeting space](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#update-meeting-space).
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws meet --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema meet.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: gws-modelarmor-create-template
|
||||
version: 1.0.0
|
||||
description: "Create a new Model Armor template"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "security"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws modelarmor +create-template --help"
|
||||
---
|
||||
|
||||
# modelarmor +create-template
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Create a new Model Armor template
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws modelarmor +create-template --project <PROJECT> --location <LOCATION> --template-id <ID>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--project` | ✓ | — | GCP project ID |
|
||||
| `--location` | ✓ | — | GCP location (e.g. us-central1) |
|
||||
| `--template-id` | ✓ | — | Template ID to create |
|
||||
| `--preset` | — | — | Use a preset template: jailbreak |
|
||||
| `--json` | — | — | JSON body for the template configuration (overrides --preset) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-modelarmor](../gws-modelarmor/SKILL.md) — All filter user-generated content for safety commands
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gws-modelarmor-sanitize-prompt
|
||||
version: 1.0.0
|
||||
description: "Sanitize a user prompt through a Model Armor template"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "security"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws modelarmor +sanitize-prompt --help"
|
||||
---
|
||||
|
||||
# modelarmor +sanitize-prompt
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Sanitize a user prompt through a Model Armor template
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws modelarmor +sanitize-prompt --template <NAME>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--template` | ✓ | — | Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE) |
|
||||
| `--text` | — | — | Text content to sanitize |
|
||||
| `--json` | — | — | Full JSON request body (overrides --text) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-modelarmor](../gws-modelarmor/SKILL.md) — All filter user-generated content for safety commands
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gws-modelarmor-sanitize-response
|
||||
version: 1.0.0
|
||||
description: "Sanitize a model response through a Model Armor template"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "security"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws modelarmor +sanitize-response --help"
|
||||
---
|
||||
|
||||
# modelarmor +sanitize-response
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Sanitize a model response through a Model Armor template
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws modelarmor +sanitize-response --template <NAME>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--template` | ✓ | — | Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE) |
|
||||
| `--text` | — | — | Text content to sanitize |
|
||||
| `--json` | — | — | Full JSON request body (overrides --text) |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-modelarmor](../gws-modelarmor/SKILL.md) — All filter user-generated content for safety commands
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: gws-modelarmor
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to filter user-generated content for safety via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws modelarmor --help"
|
||||
---
|
||||
|
||||
# modelarmor (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws modelarmor <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+sanitize-prompt`](../gws-modelarmor-sanitize-prompt/SKILL.md) | Sanitize a user prompt through a Model Armor template |
|
||||
| [`+sanitize-response`](../gws-modelarmor-sanitize-response/SKILL.md) | Sanitize a model response through a Model Armor template |
|
||||
| [`+create-template`](../gws-modelarmor-create-template/SKILL.md) | Create a new Model Armor template |
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws modelarmor --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema modelarmor.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: gws-people
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage contacts and profiles via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws people --help"
|
||||
---
|
||||
|
||||
# people (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws people <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### contactGroups
|
||||
|
||||
- `batchGet` — Get a list of contact groups owned by the authenticated user by specifying a list of contact group resource names.
|
||||
- `create` — Create a new contact group owned by the authenticated user. Created contact group names must be unique to the users contact groups. Attempting to create a group with a duplicate name will return a HTT
|
||||
- `delete` — Delete an existing contact group owned by the authenticated user by specifying a contact group resource name. Mutate requests for the same user should be sent sequentially to avoid increased latency a
|
||||
- `get` — Get a specific contact group owned by the authenticated user by specifying a contact group resource name.
|
||||
- `list` — List all contact groups owned by the authenticated user. Members of the contact groups are not populated.
|
||||
- `update` — Update the name of an existing contact group owned by the authenticated user. Updated contact group names must be unique to the users contact groups. Attempting to create a group with a duplicate name
|
||||
- `members` — Operations on the 'members' resource
|
||||
|
||||
### otherContacts
|
||||
|
||||
- `copyOtherContactToMyContactsGroup` — Copies an "Other contact" to a new contact in the user's "myContacts" group Mutate requests for the same user should be sent sequentially to avoid increased latency and failures.
|
||||
- `list` — List all "Other contacts", that is contacts that are not in a contact group. "Other contacts" are typically auto created contacts from interactions. Sync tokens expire 7 days after the full sync. A re
|
||||
- `search` — Provides a list of contacts in the authenticated user's other contacts that matches the search query. The query matches on a contact's `names`, `emailAddresses`, and `phoneNumbers` fields that are fro
|
||||
|
||||
### people
|
||||
|
||||
- `batchCreateContacts` — Create a batch of new contacts and return the PersonResponses for the newly Mutate requests for the same user should be sent sequentially to avoid increased latency and failures.
|
||||
- `batchDeleteContacts` — Delete a batch of contacts. Any non-contact data will not be deleted. Mutate requests for the same user should be sent sequentially to avoid increased latency and failures.
|
||||
- `batchUpdateContacts` — Update a batch of contacts and return a map of resource names to PersonResponses for the updated contacts. Mutate requests for the same user should be sent sequentially to avoid increased latency and
|
||||
- `createContact` — Create a new contact and return the person resource for that contact. The request returns a 400 error if more than one field is specified on a field that is a singleton for contact sources: * biograph
|
||||
- `deleteContact` — Delete a contact person. Any non-contact data will not be deleted. Mutate requests for the same user should be sent sequentially to avoid increased latency and failures.
|
||||
- `deleteContactPhoto` — Delete a contact's photo. Mutate requests for the same user should be done sequentially to avoid // lock contention.
|
||||
- `get` — Provides information about a person by specifying a resource name. Use `people/me` to indicate the authenticated user. The request returns a 400 error if 'personFields' is not specified.
|
||||
- `getBatchGet` — Provides information about a list of specific people by specifying a list of requested resource names. Use `people/me` to indicate the authenticated user. The request returns a 400 error if 'personFie
|
||||
- `listDirectoryPeople` — Provides a list of domain profiles and domain contacts in the authenticated user's domain directory. When the `sync_token` is specified, resources deleted since the last sync will be returned as a per
|
||||
- `searchContacts` — Provides a list of contacts in the authenticated user's grouped contacts that matches the search query. The query matches on a contact's `names`, `nickNames`, `emailAddresses`, `phoneNumbers`, and `or
|
||||
- `searchDirectoryPeople` — Provides a list of domain profiles and domain contacts in the authenticated user's domain directory that match the search query.
|
||||
- `updateContact` — Update contact data for an existing contact person. Any non-contact data will not be modified. Any non-contact data in the person to update will be ignored. All fields specified in the `update_mask` w
|
||||
- `updateContactPhoto` — Update a contact's photo. Mutate requests for the same user should be sent sequentially to avoid increased latency and failures.
|
||||
- `connections` — Operations on the 'connections' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws people --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema people.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: gws-reseller
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage workspace subscriptions via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws reseller --help"
|
||||
---
|
||||
|
||||
# reseller (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws reseller <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### customers
|
||||
|
||||
- `get` — Gets a customer account. Use this operation to see a customer account already in your reseller management, or to see the minimal account information for an existing customer that you do not manage. Fo
|
||||
- `insert` — Orders a new customer's account. Before ordering a new customer account, establish whether the customer account already exists using the [`customers.get`](https://developers.google.com/workspace/admin
|
||||
- `patch` — Updates a customer account's settings. This method supports patch semantics. You cannot update `customerType` via the Reseller API, but a `"team"` customer can verify their domain and become `customer
|
||||
- `update` — Updates a customer account's settings. You cannot update `customerType` via the Reseller API, but a `"team"` customer can verify their domain and become `customerType = "domain"`. For more information
|
||||
|
||||
### resellernotify
|
||||
|
||||
- `getwatchdetails` — Returns all the details of the watch corresponding to the reseller.
|
||||
- `register` — Registers a Reseller for receiving notifications.
|
||||
- `unregister` — Unregisters a Reseller for receiving notifications.
|
||||
|
||||
### subscriptions
|
||||
|
||||
- `activate` — Activates a subscription previously suspended by the reseller. If you did not suspend the customer subscription and it is suspended for any other reason, such as for abuse or a pending ToS acceptance,
|
||||
- `changePlan` — Updates a subscription plan. Use this method to update a plan for a 30-day trial or a flexible plan subscription to an annual commitment plan with monthly or yearly payments. How a plan is updated dif
|
||||
- `changeRenewalSettings` — Updates a user license's renewal settings. This is applicable for accounts with annual commitment plans only. For more information, see the description in [manage subscriptions](https://developers.goo
|
||||
- `changeSeats` — Updates a subscription's user license settings. For more information about updating an annual commitment plan or a flexible plan subscription’s licenses, see [Manage Subscriptions](https://developers.
|
||||
- `delete` — Cancels, suspends, or transfers a subscription to direct.
|
||||
- `get` — Gets a specific subscription. The `subscriptionId` can be found using the [Retrieve all reseller subscriptions](https://developers.google.com/workspace/admin/reseller/v1/how-tos/manage_subscriptions#g
|
||||
- `insert` — Creates or transfer a subscription. Create a subscription for a customer's account that you ordered using the [Order a new customer account](https://developers.google.com/workspace/admin/reseller/v1/r
|
||||
- `list` — Lists of subscriptions managed by the reseller. The list can be all subscriptions, all of a customer's subscriptions, or all of a customer's transferable subscriptions. Optionally, this method can fil
|
||||
- `startPaidService` — Immediately move a 30-day free trial subscription to a paid service subscription. This method is only applicable if a payment plan has already been set up for the 30-day trial subscription. For more i
|
||||
- `suspend` — Suspends an active subscription. You can use this method to suspend a paid subscription that is currently in the `ACTIVE` state. * For `FLEXIBLE` subscriptions, billing is paused. * For `ANNUAL_MONTHL
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws reseller --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema reseller.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: gws-shared
|
||||
version: 1.0.0
|
||||
description: "Shared patterns, authentication, and global flags for all gws commands."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
---
|
||||
|
||||
# gws — Shared Reference
|
||||
|
||||
## Installation
|
||||
|
||||
The `gws` binary must be on `$PATH`. See the project README for install options.
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Browser-based OAuth (interactive)
|
||||
gws auth login
|
||||
|
||||
# Service Account
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||
```
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format <FORMAT>` | Output format: `json` (default), `table`, `yaml`, `csv` |
|
||||
| `--dry-run` | Validate locally without calling the API |
|
||||
| `--sanitize <TEMPLATE>` | Screen responses through Model Armor |
|
||||
|
||||
## CLI Syntax
|
||||
|
||||
```bash
|
||||
gws <service> <resource> [sub-resource] <method> [flags]
|
||||
```
|
||||
|
||||
### Method Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--params '{"key": "val"}'` | URL/query parameters |
|
||||
| `--json '{"key": "val"}'` | Request body |
|
||||
| `-o, --output <PATH>` | Save binary responses to file |
|
||||
| `--upload <PATH>` | Upload file content (multipart) |
|
||||
| `--page-all` | Auto-paginate (NDJSON output) |
|
||||
| `--page-limit <N>` | Max pages when using --page-all (default: 10) |
|
||||
| `--page-delay <MS>` | Delay between pages in ms (default: 100) |
|
||||
|
||||
## Security Rules
|
||||
|
||||
- **Never** output secrets (API keys, tokens) directly
|
||||
- **Always** confirm with user before executing write/delete commands
|
||||
- Prefer `--dry-run` for destructive operations
|
||||
- Use `--sanitize` for PII/content safety screening
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gws-sheets-append
|
||||
version: 1.0.0
|
||||
description: "Append a row to a spreadsheet"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws sheets +append --help"
|
||||
---
|
||||
|
||||
# sheets +append
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Append a row to a spreadsheet
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws sheets +append --spreadsheet <ID>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--spreadsheet` | ✓ | — | Spreadsheet ID |
|
||||
| `--values` | — | — | Comma-separated values (simple strings) |
|
||||
| `--json-values` | — | — | JSON array of rows, e.g. '[["a","b"],["c","d"]]' |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws sheets +append --spreadsheet ID --values 'Alice,100,true'
|
||||
gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]'
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use --values for simple single-row appends.
|
||||
- Use --json-values for bulk multi-row inserts.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **write** command — confirm with the user before executing.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-sheets](../gws-sheets/SKILL.md) — All read and write spreadsheets commands
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: gws-sheets-read
|
||||
version: 1.0.0
|
||||
description: "Read values from a spreadsheet"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws sheets +read --help"
|
||||
---
|
||||
|
||||
# sheets +read
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Read values from a spreadsheet
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws sheets +read --spreadsheet <ID> --range <RANGE>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--spreadsheet` | ✓ | — | Spreadsheet ID |
|
||||
| `--range` | ✓ | — | Range to read (e.g. 'Sheet1!A1:B2') |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-sheets](../gws-sheets/SKILL.md) — All read and write spreadsheets commands
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: gws-sheets
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to read and write spreadsheets via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws sheets --help"
|
||||
---
|
||||
|
||||
# sheets (v4)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws sheets <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## Helper Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [`+append`](../gws-sheets-append/SKILL.md) | Append a row to a spreadsheet |
|
||||
| [`+read`](../gws-sheets-read/SKILL.md) | Read values from a spreadsheet |
|
||||
|
||||
## API Resources
|
||||
|
||||
### spreadsheets
|
||||
|
||||
- `batchUpdate` — Applies one or more updates to the spreadsheet. Each request is validated before being applied. If any request is not valid then the entire request will fail and nothing will be applied. Some requests
|
||||
- `create` — Creates a spreadsheet, returning the newly created spreadsheet.
|
||||
- `get` — Returns the spreadsheet at the given ID. The caller must specify the spreadsheet ID. By default, data within grids is not returned. You can include grid data in one of 2 ways: * Specify a [field mask]
|
||||
- `getByDataFilter` — Returns the spreadsheet at the given ID. The caller must specify the spreadsheet ID. For more information, see [Read, write, and search metadata](https://developers.google.com/workspace/sheets/api/gui
|
||||
- `developerMetadata` — Operations on the 'developerMetadata' resource
|
||||
- `sheets` — Operations on the 'sheets' resource
|
||||
- `values` — Operations on the 'values' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws sheets --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema sheets.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: gws-slides
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to read and write presentations via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws slides --help"
|
||||
---
|
||||
|
||||
# slides (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws slides <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### presentations
|
||||
|
||||
- `batchUpdate` — Applies one or more updates to the presentation. Each request is validated before being applied. If any request is not valid, then the entire request will fail and nothing will be applied. Some reques
|
||||
- `create` — Creates a blank presentation using the title given in the request. If a `presentationId` is provided, it is used as the ID of the new presentation. Otherwise, a new ID is generated. Other fields in th
|
||||
- `get` — Gets the latest version of the specified presentation.
|
||||
- `pages` — Operations on the 'pages' resource
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws slides --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema slides.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: gws-tasks
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage task lists and tasks via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws tasks --help"
|
||||
---
|
||||
|
||||
# tasks (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws tasks <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### tasklists
|
||||
|
||||
- `delete` — Deletes the authenticated user's specified task list. If the list contains assigned tasks, both the assigned tasks and the original tasks in the assignment surface (Docs, Chat Spaces) are deleted.
|
||||
- `get` — Returns the authenticated user's specified task list.
|
||||
- `insert` — Creates a new task list and adds it to the authenticated user's task lists. A user can have up to 2000 lists at a time.
|
||||
- `list` — Returns all the authenticated user's task lists. A user can have up to 2000 lists at a time.
|
||||
- `patch` — Updates the authenticated user's specified task list. This method supports patch semantics.
|
||||
- `update` — Updates the authenticated user's specified task list.
|
||||
|
||||
### tasks
|
||||
|
||||
- `clear` — Clears all completed tasks from the specified task list. The affected tasks will be marked as 'hidden' and no longer be returned by default when retrieving all tasks for a task list.
|
||||
- `delete` — Deletes the specified task from the task list. If the task is assigned, both the assigned task and the original task (in Docs, Chat Spaces) are deleted. To delete the assigned task only, navigate to t
|
||||
- `get` — Returns the specified task.
|
||||
- `insert` — Creates a new task on the specified task list. Tasks assigned from Docs or Chat Spaces cannot be inserted from Tasks Public API; they can only be created by assigning them from Docs or Chat Spaces. A
|
||||
- `list` — Returns all tasks in the specified task list. Doesn't return assigned tasks by default (from Docs, Chat Spaces). A user can have up to 20,000 non-hidden tasks per list and up to 100,000 tasks in total
|
||||
- `move` — Moves the specified task to another position in the destination task list. If the destination list is not specified, the task is moved within its current list. This can include putting it as a child t
|
||||
- `patch` — Updates the specified task. This method supports patch semantics.
|
||||
- `update` — Updates the specified task.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws tasks --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema tasks.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: gws-vault
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to manage ediscovery holds and exports via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws vault --help"
|
||||
---
|
||||
|
||||
# vault (v1)
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
```bash
|
||||
gws vault <resource> <method> [flags]
|
||||
```
|
||||
|
||||
## API Resources
|
||||
|
||||
### matters
|
||||
|
||||
- `addPermissions` — Adds an account as a matter collaborator.
|
||||
- `close` — Closes the specified matter. Returns the matter with updated state.
|
||||
- `count` — Counts the accounts processed by the specified query.
|
||||
- `create` — Creates a matter with the given name and description. The initial state is open, and the owner is the method caller. Returns the created matter with default view.
|
||||
- `delete` — Deletes the specified matter. Returns the matter with updated state.
|
||||
- `get` — Gets the specified matter.
|
||||
- `list` — Lists matters the requestor has access to.
|
||||
- `removePermissions` — Removes an account as a matter collaborator.
|
||||
- `reopen` — Reopens the specified matter. Returns the matter with updated state.
|
||||
- `undelete` — Undeletes the specified matter. Returns the matter with updated state.
|
||||
- `update` — Updates the specified matter. This updates only the name and description of the matter, identified by matter ID. Changes to any other fields are ignored. Returns the default view of the matter.
|
||||
- `exports` — Operations on the 'exports' resource
|
||||
- `holds` — Operations on the 'holds' resource
|
||||
- `savedQueries` — Operations on the 'savedQueries' resource
|
||||
|
||||
### operations
|
||||
|
||||
- `cancel` — Starts asynchronous cancellation on a long-running operation. The server makes a best effort to cancel the operation, but success is not guaranteed. If the server doesn't support this method, it retur
|
||||
- `delete` — Deletes a long-running operation. This method indicates that the client is no longer interested in the operation result. It does not cancel the operation. If the server doesn't support this method, it
|
||||
- `get` — Gets the latest state of a long-running operation. Clients can use this method to poll the operation result at intervals as recommended by the API service.
|
||||
- `list` — Lists operations that match the specified filter in the request. If the server doesn't support this method, it returns `UNIMPLEMENTED`.
|
||||
|
||||
## Discovering Commands
|
||||
|
||||
Before calling any API method, inspect it:
|
||||
|
||||
```bash
|
||||
# Browse resources and methods
|
||||
gws vault --help
|
||||
|
||||
# Inspect a method's required params, types, and defaults
|
||||
gws schema vault.<resource>.<method>
|
||||
```
|
||||
|
||||
Use `gws schema` output to build your `--params` and `--json` flags.
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
// 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 crate::credential_store;
|
||||
|
||||
/// Types of credentials we support
|
||||
#[derive(Debug)]
|
||||
enum Credential {
|
||||
AuthorizedUser(yup_oauth2::authorized_user::AuthorizedUserSecret),
|
||||
ServiceAccount(yup_oauth2::ServiceAccountKey),
|
||||
}
|
||||
|
||||
/// 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` (User only)
|
||||
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
|
||||
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 impersonated_user = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER").ok();
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("gws");
|
||||
|
||||
let enc_path = credential_store::encrypted_credentials_path();
|
||||
let default_path = config_dir.join("credentials.json");
|
||||
|
||||
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
|
||||
|
||||
get_token_inner(scopes, creds, &config_dir, impersonated_user.as_deref()).await
|
||||
}
|
||||
|
||||
async fn get_token_inner(
|
||||
scopes: &[&str],
|
||||
creds: Credential,
|
||||
config_dir: &std::path::Path,
|
||||
impersonated_user: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
match creds {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
let token_cache = config_dir.join("token_cache.json");
|
||||
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret)
|
||||
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
|
||||
token_cache,
|
||||
)))
|
||||
.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 token_cache = config_dir.join("service_account_token_cache.json");
|
||||
let mut builder =
|
||||
yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(Box::new(
|
||||
crate::token_storage::EncryptedTokenStorage::new(token_cache),
|
||||
));
|
||||
|
||||
// Check for impersonation
|
||||
if let Some(user) = impersonated_user {
|
||||
if !user.trim().is_empty() {
|
||||
builder = builder.subject(user.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// Read file content first to determine type
|
||||
let content = tokio::fs::read_to_string(&p)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read credentials from {path}"))?;
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&content).context("Failed to parse credentials JSON")?;
|
||||
|
||||
// Check for "type" field
|
||||
if let Some(type_str) = json.get("type").and_then(|v| v.as_str()) {
|
||||
if type_str == "service_account" {
|
||||
let key = yup_oauth2::parse_service_account_key(&content)
|
||||
.context("Failed to parse service account key")?;
|
||||
return Ok(Credential::ServiceAccount(key));
|
||||
}
|
||||
}
|
||||
|
||||
// Default to parsed authorized user secret if not service account
|
||||
// We re-parse specifically to AuthorizedUserSecret to validate fields
|
||||
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret =
|
||||
serde_json::from_str(&content)
|
||||
.context("Failed to parse authorized user credentials")?;
|
||||
return Ok(Credential::AuthorizedUser(secret));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Encrypted credentials (always AuthorizedUser for now)
|
||||
if enc_path.exists() {
|
||||
let json_str = credential_store::load_encrypted_from_path(enc_path)
|
||||
.context("Failed to decrypt credentials")?;
|
||||
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&json_str).context("Failed to parse decrypted credentials")?;
|
||||
|
||||
let client_id = creds["client_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing client_id in encrypted credentials"))?;
|
||||
let client_secret = creds["client_secret"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing client_secret in encrypted credentials"))?;
|
||||
// refresh_token is optional now in some flows, but strictly required for this storage format
|
||||
let refresh_token = creds["refresh_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing refresh_token in encrypted credentials"))?;
|
||||
|
||||
return Ok(Credential::AuthorizedUser(
|
||||
yup_oauth2::authorized_user::AuthorizedUserSecret {
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
key_type: "authorized_user".to_string(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Plaintext credentials at default path (Default to 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())
|
||||
})?,
|
||||
));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"No credentials found. Run `gws auth setup` to configure, \
|
||||
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_no_options() {
|
||||
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]
|
||||
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() {
|
||||
// Save the old token
|
||||
let old_token = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN").ok();
|
||||
|
||||
// Set the token env var
|
||||
unsafe {
|
||||
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
|
||||
}
|
||||
|
||||
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
|
||||
|
||||
unsafe {
|
||||
if let Some(t) = old_token {
|
||||
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", t);
|
||||
} else {
|
||||
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
|
||||
}
|
||||
}
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "my-test-token");
|
||||
}
|
||||
|
||||
#[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.
|
||||
// We test with non-existent credential paths to ensure fallthrough.
|
||||
unsafe {
|
||||
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "");
|
||||
}
|
||||
|
||||
let result = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/does/not/exist1"),
|
||||
&PathBuf::from("/does/not/exist2"),
|
||||
)
|
||||
.await;
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Format: name/version
|
||||
let client_header = format!("{}/{}", 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)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
crate::error::GwsError::Other(anyhow::anyhow!("Failed to build HTTP client: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Send an HTTP request with automatic retry on 429 (rate limit) responses.
|
||||
/// 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> {
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
let resp = build_request().send().await?;
|
||||
|
||||
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Parse Retry-After header (seconds), fall back to exponential backoff
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(1 << attempt); // 1, 2, 4 seconds
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
|
||||
}
|
||||
|
||||
// Final attempt — return whatever we get
|
||||
build_request().send().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_client_succeeds() {
|
||||
assert!(build_client().is_ok());
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
// 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 = method
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
// Truncate long descriptions for help text
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// 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::path::PathBuf;
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
|
||||
|
||||
use keyring::Entry;
|
||||
use rand::RngCore;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns the encryption key derived from the OS keyring, or falls back to a local file.
|
||||
/// Generates a random 256-bit key and stores it securely if it doesn't exist.
|
||||
fn get_or_create_key() -> anyhow::Result<[u8; 32]> {
|
||||
static KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
if let Some(key) = KEY.get() {
|
||||
return Ok(*key);
|
||||
}
|
||||
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-user".to_string());
|
||||
|
||||
let entry = Entry::new("gws-cli", &username);
|
||||
|
||||
if let Ok(entry) = entry {
|
||||
match entry.get_password() {
|
||||
Ok(b64_key) => {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
if let Ok(decoded) = STANDARD.decode(&b64_key) {
|
||||
if decoded.len() == 32 {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&decoded);
|
||||
let _ = KEY.set(arr);
|
||||
return Ok(arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
// Generate a random 32-byte key
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
let b64_key = STANDARD.encode(key);
|
||||
|
||||
if entry.set_password(&b64_key).is_ok() {
|
||||
let _ = KEY.set(key);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
Err(_) => {} // Fallthrough to file storage
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Local file `.encryption_key`
|
||||
let key_file = crate::auth_commands::config_dir().join(".encryption_key");
|
||||
if key_file.exists() {
|
||||
if let Ok(b64_key) = std::fs::read_to_string(&key_file) {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
if let Ok(decoded) = STANDARD.decode(b64_key.trim()) {
|
||||
if decoded.len() == 32 {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&decoded);
|
||||
let _ = KEY.set(arr);
|
||||
return Ok(arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new key and save to local file
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
let b64_key = STANDARD.encode(key);
|
||||
|
||||
if let Some(parent) = key_file.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true).mode(0o600);
|
||||
if let Ok(mut file) = options.open(&key_file) {
|
||||
use std::io::Write;
|
||||
let _ = file.write_all(b64_key.as_bytes());
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = std::fs::write(&key_file, b64_key);
|
||||
}
|
||||
|
||||
let _ = KEY.set(key);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypts plaintext bytes using AES-256-GCM with a machine-derived key.
|
||||
/// Returns nonce (12 bytes) || ciphertext.
|
||||
pub fn encrypt(plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let key = get_or_create_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?;
|
||||
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))?;
|
||||
|
||||
// Prepend nonce to ciphertext
|
||||
let mut result = nonce.to_vec();
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Decrypts data produced by `encrypt()`.
|
||||
pub fn decrypt(data: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted data too short");
|
||||
}
|
||||
|
||||
let key = get_or_create_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?;
|
||||
|
||||
let nonce = Nonce::from_slice(&data[..12]);
|
||||
let plaintext = cipher.decrypt(nonce, &data[12..]).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Decryption failed. Credentials may have been created on a different machine. \
|
||||
Run `gws auth logout` and `gws auth login` to re-authenticate."
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Returns the path for encrypted credentials.
|
||||
pub fn encrypted_credentials_path() -> PathBuf {
|
||||
crate::auth_commands::config_dir().join("credentials.enc")
|
||||
}
|
||||
|
||||
/// Saves credentials JSON to an encrypted file.
|
||||
pub fn save_encrypted(json: &str) -> anyhow::Result<PathBuf> {
|
||||
let path = encrypted_credentials_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
|
||||
let encrypted = encrypt(json.as_bytes())?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true).mode(0o600);
|
||||
let mut file = options.open(&path)?;
|
||||
use std::io::Write;
|
||||
file.write_all(&encrypted)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, encrypted)?;
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Loads and decrypts credentials JSON from a specific path.
|
||||
pub fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let data = std::fs::read(path)?;
|
||||
let plaintext = decrypt(&data)?;
|
||||
Ok(String::from_utf8(plaintext)?)
|
||||
}
|
||||
|
||||
/// Loads and decrypts credentials JSON from the default encrypted file.
|
||||
pub fn load_encrypted() -> anyhow::Result<String> {
|
||||
load_encrypted_from_path(&encrypted_credentials_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let plaintext = b"hello, world!";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
|
||||
// Encrypted data should be different from plaintext
|
||||
assert_ne!(&encrypted, plaintext);
|
||||
|
||||
// Should be nonce (12) + ciphertext (plaintext + 16 byte tag)
|
||||
assert_eq!(encrypted.len(), 12 + plaintext.len() + 16);
|
||||
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_empty() {
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_json_credentials() {
|
||||
let json = r#"{"type":"authorized_user","client_id":"test.apps.googleusercontent.com","client_secret":"secret","refresh_token":"1//token"}"#;
|
||||
let encrypted = encrypt(json.as_bytes()).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(String::from_utf8(decrypted).unwrap(), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_large_payload() {
|
||||
let plaintext: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
|
||||
let encrypted = encrypt(&plaintext).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_short_data() {
|
||||
let result = decrypt(&[0u8; 11]);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("too short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_tampered_ciphertext() {
|
||||
let encrypted = encrypt(b"secret data").expect("encryption should succeed");
|
||||
|
||||
// Tamper with the ciphertext (after the 12-byte nonce)
|
||||
let mut tampered = encrypted.clone();
|
||||
if tampered.len() > 12 {
|
||||
tampered[12] ^= 0xFF;
|
||||
}
|
||||
|
||||
let result = decrypt(&tampered);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Decryption failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_tampered_nonce() {
|
||||
let encrypted = encrypt(b"secret data").expect("encryption should succeed");
|
||||
|
||||
let mut tampered = encrypted.clone();
|
||||
tampered[0] ^= 0xFF;
|
||||
|
||||
let result = decrypt(&tampered);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_encryption_produces_different_output() {
|
||||
let plaintext = b"same input";
|
||||
let enc1 = encrypt(plaintext).expect("encryption should succeed");
|
||||
let enc2 = encrypt(plaintext).expect("encryption should succeed");
|
||||
|
||||
// Different nonces should produce different ciphertext
|
||||
assert_ne!(enc1, enc2);
|
||||
|
||||
// But both should decrypt to the same plaintext
|
||||
let dec1 = decrypt(&enc1).unwrap();
|
||||
let dec2 = decrypt(&enc2).unwrap();
|
||||
assert_eq!(dec1, dec2);
|
||||
assert_eq!(dec1, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_create_key_is_deterministic() {
|
||||
let key1 = get_or_create_key().unwrap();
|
||||
let key2 = get_or_create_key().unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_create_key_produces_256_bits() {
|
||||
let key = get_or_create_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
#![allow(dead_code)]
|
||||
// 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 Parsing and Management
|
||||
//!
|
||||
//! Handles fetching, caching, and parsing Google API Discovery Documents.
|
||||
//! These JSON schemas define the shapes of API requests and responses, forming
|
||||
//! the foundation of the dynamically generated CLI commands.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Top-level Discovery REST Description document.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestDescription {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub root_url: String,
|
||||
#[serde(default)]
|
||||
pub service_path: String,
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub schemas: HashMap<String, JsonSchema>,
|
||||
#[serde(default)]
|
||||
pub resources: HashMap<String, RestResource>,
|
||||
#[serde(default)]
|
||||
pub parameters: HashMap<String, MethodParameter>,
|
||||
pub auth: Option<AuthDescription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct AuthDescription {
|
||||
pub oauth2: Option<OAuth2Description>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct OAuth2Description {
|
||||
pub scopes: Option<HashMap<String, ScopeDescription>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ScopeDescription {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A resource in the Discovery Document, which can contain methods and nested sub-resources.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct RestResource {
|
||||
#[serde(default)]
|
||||
pub methods: HashMap<String, RestMethod>,
|
||||
#[serde(default)]
|
||||
pub resources: HashMap<String, RestResource>,
|
||||
}
|
||||
|
||||
/// A single API method.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestMethod {
|
||||
pub id: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub http_method: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub parameters: HashMap<String, MethodParameter>,
|
||||
#[serde(default)]
|
||||
pub parameter_order: Vec<String>,
|
||||
pub request: Option<SchemaRef>,
|
||||
pub response: Option<SchemaRef>,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
pub flat_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub supports_media_download: bool,
|
||||
#[serde(default)]
|
||||
pub supports_media_upload: bool,
|
||||
pub media_upload: Option<MediaUpload>,
|
||||
}
|
||||
|
||||
/// Media upload metadata from the Discovery Document.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct MediaUpload {
|
||||
pub protocols: Option<MediaUploadProtocols>,
|
||||
pub accept: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Upload protocol details.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct MediaUploadProtocols {
|
||||
pub simple: Option<MediaUploadProtocol>,
|
||||
}
|
||||
|
||||
/// A single upload protocol entry.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct MediaUploadProtocol {
|
||||
pub path: String,
|
||||
pub multipart: Option<bool>,
|
||||
}
|
||||
|
||||
/// A reference to a schema (e.g., `{ "$ref": "File" }`).
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct SchemaRef {
|
||||
#[serde(rename = "$ref")]
|
||||
pub schema_ref: Option<String>,
|
||||
#[serde(rename = "parameterName")]
|
||||
pub parameter_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A parameter definition for a method.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MethodParameter {
|
||||
#[serde(rename = "type")]
|
||||
pub param_type: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub format: Option<String>,
|
||||
pub default: Option<String>,
|
||||
#[serde(rename = "enum")]
|
||||
pub enum_values: Option<Vec<String>>,
|
||||
pub enum_descriptions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub repeated: bool,
|
||||
pub minimum: Option<String>,
|
||||
pub maximum: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deprecated: bool,
|
||||
}
|
||||
|
||||
/// JSON Schema definition for request/response bodies.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsonSchema {
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub schema_type: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, JsonSchemaProperty>,
|
||||
#[serde(rename = "$ref")]
|
||||
pub schema_ref: Option<String>,
|
||||
pub items: Option<Box<JsonSchemaProperty>>,
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
pub additional_properties: Option<Box<JsonSchemaProperty>>,
|
||||
}
|
||||
|
||||
/// A property within a JSON Schema.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsonSchemaProperty {
|
||||
#[serde(rename = "type")]
|
||||
pub prop_type: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "$ref")]
|
||||
pub schema_ref: Option<String>,
|
||||
pub format: Option<String>,
|
||||
pub items: Option<Box<JsonSchemaProperty>>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, JsonSchemaProperty>,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
pub default: Option<String>,
|
||||
#[serde(rename = "enum")]
|
||||
pub enum_values: Option<Vec<String>>,
|
||||
pub additional_properties: Option<Box<JsonSchemaProperty>>,
|
||||
}
|
||||
|
||||
/// Fetches and caches a Google Discovery Document.
|
||||
pub async fn fetch_discovery_document(
|
||||
service: &str,
|
||||
version: &str,
|
||||
) -> anyhow::Result<RestDescription> {
|
||||
let cache_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("gws")
|
||||
.join("cache");
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
|
||||
let cache_file = cache_dir.join(format!("{service}_{version}.json"));
|
||||
|
||||
// Check cache (24hr TTL)
|
||||
if cache_file.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(&cache_file) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) {
|
||||
let data = std::fs::read_to_string(&cache_file)?;
|
||||
let doc: RestDescription = serde_json::from_str(&data)?;
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("https://www.googleapis.com/discovery/v1/apis/{service}/{version}/rest");
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let resp = client.get(&url).send().await?;
|
||||
|
||||
let body = if resp.status().is_success() {
|
||||
resp.text().await?
|
||||
} else {
|
||||
// Try the $discovery/rest URL pattern used by newer APIs (Forms, Keep, Meet, etc.)
|
||||
let alt_url = format!("https://{service}.googleapis.com/$discovery/rest?version={version}");
|
||||
let alt_resp = client.get(&alt_url).send().await?;
|
||||
if !alt_resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"Failed to fetch Discovery Document for {service}/{version}: HTTP {} (tried both standard and $discovery URLs)",
|
||||
alt_resp.status()
|
||||
);
|
||||
}
|
||||
alt_resp.text().await?
|
||||
};
|
||||
|
||||
// Write to cache
|
||||
if let Err(e) = std::fs::write(&cache_file, &body) {
|
||||
// Non-fatal: just warn via stderr-safe approach
|
||||
let _ = e;
|
||||
}
|
||||
|
||||
let doc: RestDescription = serde_json::from_str(&body)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_rest_description() {
|
||||
let json = r#"{
|
||||
"name": "drive",
|
||||
"version": "v3",
|
||||
"rootUrl": "https://www.googleapis.com/",
|
||||
"servicePath": "drive/v3/",
|
||||
"resources": {
|
||||
"files": {
|
||||
"methods": {
|
||||
"list": {
|
||||
"httpMethod": "GET",
|
||||
"path": "files",
|
||||
"response": { "$ref": "FileList" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"FileList": {
|
||||
"id": "FileList",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "File" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let doc: RestDescription = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(doc.name, "drive");
|
||||
assert_eq!(doc.version, "v3");
|
||||
assert_eq!(doc.root_url, "https://www.googleapis.com/");
|
||||
assert_eq!(doc.service_path, "drive/v3/");
|
||||
|
||||
// precise resource checking
|
||||
let files = doc.resources.get("files").expect("files resource missing");
|
||||
let list = files.methods.get("list").expect("list method missing");
|
||||
assert_eq!(list.http_method, "GET");
|
||||
assert_eq!(list.path, "files");
|
||||
|
||||
// schema checking
|
||||
let file_list = doc
|
||||
.schemas
|
||||
.get("FileList")
|
||||
.expect("FileList schema missing");
|
||||
assert_eq!(file_list.id.as_deref(), Some("FileList"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_defaults() {
|
||||
let json = r#"{
|
||||
"name": "admin",
|
||||
"version": "directory_v1",
|
||||
"rootUrl": "https://admin.googleapis.com/"
|
||||
}"#;
|
||||
|
||||
let doc: RestDescription = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(doc.service_path, ""); // default empty string
|
||||
assert!(doc.resources.is_empty());
|
||||
assert!(doc.schemas.is_empty());
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// 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 serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum GwsError {
|
||||
#[error("{message}")]
|
||||
Api {
|
||||
code: u16,
|
||||
message: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Discovery(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl GwsError {
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
GwsError::Api {
|
||||
code,
|
||||
message,
|
||||
reason,
|
||||
} => json!({
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
}
|
||||
}),
|
||||
GwsError::Validation(msg) => json!({
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": msg,
|
||||
"reason": "validationError",
|
||||
}
|
||||
}),
|
||||
GwsError::Auth(msg) => json!({
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": msg,
|
||||
"reason": "authError",
|
||||
}
|
||||
}),
|
||||
GwsError::Discovery(msg) => json!({
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": msg,
|
||||
"reason": "discoveryError",
|
||||
}
|
||||
}),
|
||||
GwsError::Other(e) => json!({
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": format!("{e:#}"),
|
||||
"reason": "internalError",
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats any error as a JSON object and prints to stdout.
|
||||
pub fn print_error_json(err: &GwsError) {
|
||||
let json = err.to_json();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_to_json_api() {
|
||||
let err = GwsError::Api {
|
||||
code: 404,
|
||||
message: "Not Found".to_string(),
|
||||
reason: "notFound".to_string(),
|
||||
};
|
||||
let json = err.to_json();
|
||||
assert_eq!(json["error"]["code"], 404);
|
||||
assert_eq!(json["error"]["message"], "Not Found");
|
||||
assert_eq!(json["error"]["reason"], "notFound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_json_validation() {
|
||||
let err = GwsError::Validation("Invalid input".to_string());
|
||||
let json = err.to_json();
|
||||
assert_eq!(json["error"]["code"], 400);
|
||||
assert_eq!(json["error"]["message"], "Invalid input");
|
||||
assert_eq!(json["error"]["reason"], "validationError");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_json_auth() {
|
||||
let err = GwsError::Auth("Token expired".to_string());
|
||||
let json = err.to_json();
|
||||
assert_eq!(json["error"]["code"], 401);
|
||||
assert_eq!(json["error"]["message"], "Token expired");
|
||||
assert_eq!(json["error"]["reason"], "authError");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_json_discovery() {
|
||||
let err = GwsError::Discovery("Failed to fetch doc".to_string());
|
||||
let json = err.to_json();
|
||||
assert_eq!(json["error"]["code"], 500);
|
||||
assert_eq!(json["error"]["message"], "Failed to fetch doc");
|
||||
assert_eq!(json["error"]["reason"], "discoveryError");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_json_other() {
|
||||
let err = GwsError::Other(anyhow::anyhow!("Something went wrong"));
|
||||
let json = err.to_json();
|
||||
assert_eq!(json["error"]["code"], 500);
|
||||
assert_eq!(json["error"]["message"], "Something went wrong");
|
||||
assert_eq!(json["error"]["reason"], "internalError");
|
||||
}
|
||||
}
|
||||
+1296
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
// 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.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"table" => Self::Table,
|
||||
"yaml" | "yml" => Self::Yaml,
|
||||
"csv" => Self::Csv,
|
||||
_ => 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 as compact JSON (for NDJSON pagination mode).
|
||||
pub fn format_value_compact(value: &Value, format: &OutputFormat) -> String {
|
||||
match format {
|
||||
OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(),
|
||||
_ => format_value(value, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// 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)
|
||||
} else if let Value::Array(arr) = value {
|
||||
format_array_as_table(arr)
|
||||
} else if let Value::Object(obj) = value {
|
||||
// Single object: key/value table
|
||||
let mut output = String::new();
|
||||
let max_key_len = obj.keys().map(|k| k.len()).max().unwrap_or(0);
|
||||
for (key, val) in obj {
|
||||
let val_str = value_to_cell(val);
|
||||
let _ = writeln!(output, "{:width$} {}", key, val_str, width = max_key_len);
|
||||
}
|
||||
output
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_array_as_table(arr: &[Value]) -> String {
|
||||
if arr.is_empty() {
|
||||
return "(empty)\n".to_string();
|
||||
}
|
||||
|
||||
// Collect all unique keys across all objects
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
let mut widths: Vec<usize> = columns.iter().map(|c| c.len()).collect();
|
||||
let rows: Vec<Vec<String>> = arr
|
||||
.iter()
|
||||
.map(|item| {
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, col)| {
|
||||
let cell = if let Value::Object(obj) = item {
|
||||
obj.get(col).map(value_to_cell).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if cell.len() > widths[i] {
|
||||
widths[i] = cell.len();
|
||||
}
|
||||
// Cap column width at 60
|
||||
if widths[i] > 60 {
|
||||
widths[i] = 60;
|
||||
}
|
||||
cell
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// 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
|
||||
for row in &rows {
|
||||
let cells: Vec<String> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let truncated = if c.len() > widths[i] {
|
||||
format!("{}…", &c[..widths[i] - 1])
|
||||
} else {
|
||||
c.clone()
|
||||
};
|
||||
format!("{:width$}", truncated, width = widths[i])
|
||||
})
|
||||
.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') || s.contains(':') || s.contains('#') {
|
||||
format!(
|
||||
"|\n{}",
|
||||
s.lines()
|
||||
.map(|l| format!("{prefix} {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
} else {
|
||||
format!("\"{s}\"")
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
// 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
|
||||
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_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_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_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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// 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.
|
||||
|
||||
//! Generates SKILL.md files from the CLI's own clap metadata.
|
||||
//!
|
||||
//! Usage: `gws generate-skills [--output-dir skills/]`
|
||||
|
||||
use crate::commands;
|
||||
use crate::discovery;
|
||||
use crate::error::GwsError;
|
||||
use crate::services;
|
||||
use clap::Command;
|
||||
use std::path::Path;
|
||||
|
||||
/// Entry point for `gws generate-skills`.
|
||||
pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> {
|
||||
let output_dir = parse_output_dir(args);
|
||||
let output_path = Path::new(&output_dir);
|
||||
let filter = parse_filter(args);
|
||||
|
||||
// Generate gws-shared skill if no filter or "shared" is in the filter
|
||||
if filter
|
||||
.as_ref()
|
||||
.is_none_or(|f| "shared".contains(f.as_str()))
|
||||
{
|
||||
generate_shared_skill(output_path)?;
|
||||
}
|
||||
|
||||
for entry in services::SERVICES {
|
||||
let alias = entry.aliases[0];
|
||||
|
||||
let skill_name = format!("gws-{alias}");
|
||||
|
||||
eprintln!(
|
||||
"Generating skills for {alias} ({}/{})...",
|
||||
entry.api_name, entry.version
|
||||
);
|
||||
|
||||
// Fetch discovery doc
|
||||
let doc = match discovery::fetch_discovery_document(entry.api_name, entry.version).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!(" WARNING: Failed to fetch discovery doc for {alias}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Build the CLI tree (includes helpers)
|
||||
let cli = commands::build_cli(&doc);
|
||||
|
||||
// Collect helper commands (start with '+') and resource commands
|
||||
let mut helpers = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
|
||||
for sub in cli.get_subcommands() {
|
||||
let name = sub.get_name();
|
||||
if name.starts_with('+') {
|
||||
helpers.push(sub);
|
||||
} else {
|
||||
resources.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate service-level skill (only if service itself is in the filter, or no filter)
|
||||
let emit_service = match filter {
|
||||
Some(ref f) => alias.contains(f.as_str()),
|
||||
None => true,
|
||||
};
|
||||
if emit_service {
|
||||
let service_md = render_service_skill(alias, entry, &helpers, &resources);
|
||||
write_skill(output_path, &skill_name, &service_md)?;
|
||||
}
|
||||
|
||||
// Generate per-helper skills
|
||||
for helper in &helpers {
|
||||
let helper_name = helper.get_name();
|
||||
// +triage -> triage
|
||||
let short = helper_name.trim_start_matches('+');
|
||||
let helper_key = format!("{alias}-{short}");
|
||||
|
||||
let emit_helper = match filter {
|
||||
Some(ref f) => helper_key.contains(f.as_str()),
|
||||
None => true,
|
||||
};
|
||||
if emit_helper {
|
||||
let helper_skill_name = format!("gws-{helper_key}");
|
||||
let helper_md = render_helper_skill(alias, helper_name, helper, entry);
|
||||
write_skill(output_path, &helper_skill_name, &helper_md)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("\nDone. Skills written to {output_dir}/");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_output_dir(args: &[String]) -> String {
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
if arg == "--output-dir" {
|
||||
if let Some(val) = args.get(i + 1) {
|
||||
return val.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
"skills".to_string()
|
||||
}
|
||||
|
||||
/// Parse `--filter <match>` into a substring filter.
|
||||
fn parse_filter(args: &[String]) -> Option<String> {
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
if arg == "--filter" {
|
||||
if let Some(val) = args.get(i + 1) {
|
||||
return Some(val.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn write_skill(base: &Path, name: &str, content: &str) -> Result<(), GwsError> {
|
||||
let dir = base.join(name);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||
GwsError::Validation(format!("Failed to create dir {}: {e}", dir.display()))
|
||||
})?;
|
||||
let path = dir.join("SKILL.md");
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| GwsError::Validation(format!("Failed to write {}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_service_skill(
|
||||
alias: &str,
|
||||
entry: &services::ServiceEntry,
|
||||
helpers: &[&Command],
|
||||
resources: &[&Command],
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
// Frontmatter
|
||||
out.push_str(&format!(
|
||||
r#"---
|
||||
name: gws-{alias}
|
||||
version: 1.0.0
|
||||
description: "USE WHEN the user wants to {description} via the `gws` CLI."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws {alias} --help"
|
||||
---
|
||||
|
||||
"#,
|
||||
description = entry.description.to_lowercase(),
|
||||
));
|
||||
|
||||
// Title
|
||||
let api_version = entry.version;
|
||||
out.push_str(&format!("# {alias} ({api_version})\n\n"));
|
||||
|
||||
out.push_str(
|
||||
"> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.\n\n",
|
||||
);
|
||||
|
||||
out.push_str(&format!(
|
||||
"```bash\ngws {alias} <resource> <method> [flags]\n```\n\n",
|
||||
));
|
||||
|
||||
// Helper commands
|
||||
if !helpers.is_empty() {
|
||||
out.push_str("## Helper Commands\n\n");
|
||||
out.push_str("| Command | Description |\n");
|
||||
out.push_str("|---------|-------------|\n");
|
||||
for h in helpers {
|
||||
let name = h.get_name();
|
||||
let short = name.trim_start_matches('+');
|
||||
let about = h.get_about().map(|s| s.to_string()).unwrap_or_default();
|
||||
// Strip the "[Helper] " prefix if present
|
||||
let about = about.strip_prefix("[Helper] ").unwrap_or(&about);
|
||||
out.push_str(&format!(
|
||||
"| [`{name}`](../gws-{alias}-{short}/SKILL.md) | {about} |\n"
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// API resources
|
||||
if !resources.is_empty() {
|
||||
out.push_str("## API Resources\n\n");
|
||||
for res in resources {
|
||||
let res_name = res.get_name();
|
||||
let methods: Vec<String> = res
|
||||
.get_subcommands()
|
||||
.map(|m| {
|
||||
let mname = m.get_name().to_string();
|
||||
let mabout = m.get_about().map(|s| s.to_string()).unwrap_or_default();
|
||||
format!(" - `{mname}` — {mabout}")
|
||||
})
|
||||
.collect();
|
||||
|
||||
if methods.is_empty() {
|
||||
// Might have sub-resources, list them
|
||||
let subs: Vec<String> = res
|
||||
.get_subcommands()
|
||||
.filter(|s| s.get_subcommands().next().is_some())
|
||||
.map(|s| format!(" - `{}`", s.get_name()))
|
||||
.collect();
|
||||
if !subs.is_empty() {
|
||||
out.push_str(&format!("### {res_name}\n\n"));
|
||||
for s in subs {
|
||||
out.push_str(&s);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
} else {
|
||||
out.push_str(&format!("### {res_name}\n\n"));
|
||||
for m in &methods {
|
||||
out.push_str(m);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovering commands section
|
||||
out.push_str("## Discovering Commands\n\n");
|
||||
out.push_str("Before calling any API method, inspect it:\n\n");
|
||||
out.push_str(&format!("```bash\n# Browse resources and methods\ngws {alias} --help\n\n# Inspect a method's required params, types, and defaults\ngws schema {alias}.<resource>.<method>\n```\n\n"));
|
||||
out.push_str("Use `gws schema` output to build your `--params` and `--json` flags.\n\n");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn render_helper_skill(
|
||||
alias: &str,
|
||||
cmd_name: &str,
|
||||
cmd: &Command,
|
||||
entry: &services::ServiceEntry,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
let about_raw = cmd.get_about().map(|s| s.to_string()).unwrap_or_default();
|
||||
let about = about_raw.strip_prefix("[Helper] ").unwrap_or(&about_raw);
|
||||
|
||||
let short = cmd_name.trim_start_matches('+');
|
||||
|
||||
// Determine if write command
|
||||
let is_write = matches!(
|
||||
short,
|
||||
"send"
|
||||
| "write"
|
||||
| "upload"
|
||||
| "push"
|
||||
| "insert"
|
||||
| "append"
|
||||
| "create-template"
|
||||
| "subscribe"
|
||||
);
|
||||
let category = if alias == "modelarmor" {
|
||||
"security"
|
||||
} else {
|
||||
"productivity"
|
||||
};
|
||||
|
||||
// Frontmatter
|
||||
out.push_str(&format!(
|
||||
r#"---
|
||||
name: gws-{alias}-{short}
|
||||
version: 1.0.0
|
||||
description: "{about}"
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "{category}"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws {alias} {cmd_name} --help"
|
||||
---
|
||||
|
||||
"#,
|
||||
));
|
||||
|
||||
// Title
|
||||
out.push_str(&format!("# {alias} {cmd_name}\n\n"));
|
||||
|
||||
out.push_str(
|
||||
"> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.\n\n",
|
||||
);
|
||||
|
||||
out.push_str(&format!("{about}\n\n"));
|
||||
|
||||
// Usage
|
||||
out.push_str("## Usage\n\n");
|
||||
out.push_str(&format!("```bash\ngws {alias} {cmd_name}"));
|
||||
|
||||
// Show required args inline
|
||||
let args: Vec<_> = cmd
|
||||
.get_arguments()
|
||||
.filter(|a| a.get_id() != "help")
|
||||
.collect();
|
||||
for arg in &args {
|
||||
if arg.is_required_set() {
|
||||
if let Some(long) = arg.get_long() {
|
||||
let val_name = arg
|
||||
.get_value_names()
|
||||
.and_then(|v| v.first())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "VALUE".to_string());
|
||||
out.push_str(&format!(" --{long} <{val_name}>"));
|
||||
} else {
|
||||
let id = arg.get_id().as_str();
|
||||
out.push_str(&format!(" <{id}>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
// Flags table
|
||||
if !args.is_empty() {
|
||||
out.push_str("## Flags\n\n");
|
||||
out.push_str("| Flag | Required | Default | Description |\n");
|
||||
out.push_str("|------|----------|---------|-------------|\n");
|
||||
|
||||
for arg in &args {
|
||||
let flag = if let Some(long) = arg.get_long() {
|
||||
format!("`--{long}`")
|
||||
} else {
|
||||
format!("`<{}>`", arg.get_id().as_str())
|
||||
};
|
||||
|
||||
let required = if arg.is_required_set() { "✓" } else { "—" };
|
||||
|
||||
// Get default value
|
||||
let default = arg
|
||||
.get_default_values()
|
||||
.first()
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let help = arg
|
||||
.get_help()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
out.push_str(&format!("| {flag} | {required} | {default} | {help} |\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// After-help (examples, tips) — format as proper markdown
|
||||
if let Some(after) = cmd.get_after_help() {
|
||||
let after_str = after.to_string();
|
||||
if !after_str.is_empty() {
|
||||
let mut in_examples = false;
|
||||
let mut in_tips = false;
|
||||
let mut examples = Vec::new();
|
||||
let mut tips = Vec::new();
|
||||
|
||||
for line in after_str.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "EXAMPLES:" {
|
||||
in_examples = true;
|
||||
in_tips = false;
|
||||
continue;
|
||||
}
|
||||
if trimmed == "TIPS:" {
|
||||
in_tips = true;
|
||||
in_examples = false;
|
||||
continue;
|
||||
}
|
||||
if in_examples && !trimmed.is_empty() {
|
||||
examples.push(trimmed.to_string());
|
||||
}
|
||||
if in_tips && !trimmed.is_empty() {
|
||||
tips.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !examples.is_empty() {
|
||||
out.push_str("## Examples\n\n```bash\n");
|
||||
for ex in &examples {
|
||||
out.push_str(ex);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("```\n\n");
|
||||
}
|
||||
|
||||
if !tips.is_empty() {
|
||||
out.push_str("## Tips\n\n");
|
||||
for tip in &tips {
|
||||
out.push_str(&format!("- {tip}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write warning
|
||||
if is_write {
|
||||
out.push_str("> [!CAUTION]\n");
|
||||
out.push_str("> This is a **write** command — confirm with the user before executing.\n\n");
|
||||
}
|
||||
|
||||
// Cross-reference
|
||||
out.push_str(&format!(
|
||||
"## See Also\n\n- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth\n- [gws-{alias}](../gws-{alias}/SKILL.md) — All {} commands\n",
|
||||
entry.description.to_lowercase(),
|
||||
));
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn generate_shared_skill(base: &Path) -> Result<(), GwsError> {
|
||||
let content = r#"---
|
||||
name: gws-shared
|
||||
version: 1.0.0
|
||||
description: "Shared patterns, authentication, and global flags for all gws commands."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
---
|
||||
|
||||
# gws — Shared Reference
|
||||
|
||||
## Installation
|
||||
|
||||
The `gws` binary must be on `$PATH`. See the project README for install options.
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Browser-based OAuth (interactive)
|
||||
gws auth login
|
||||
|
||||
# Service Account
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||
```
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format <FORMAT>` | Output format: `json` (default), `table`, `yaml`, `csv` |
|
||||
| `--dry-run` | Validate locally without calling the API |
|
||||
| `--sanitize <TEMPLATE>` | Screen responses through Model Armor |
|
||||
|
||||
## CLI Syntax
|
||||
|
||||
```bash
|
||||
gws <service> <resource> [sub-resource] <method> [flags]
|
||||
```
|
||||
|
||||
### Method Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--params '{"key": "val"}'` | URL/query parameters |
|
||||
| `--json '{"key": "val"}'` | Request body |
|
||||
| `-o, --output <PATH>` | Save binary responses to file |
|
||||
| `--upload <PATH>` | Upload file content (multipart) |
|
||||
| `--page-all` | Auto-paginate (NDJSON output) |
|
||||
| `--page-limit <N>` | Max pages when using --page-all (default: 10) |
|
||||
| `--page-delay <MS>` | Delay between pages in ms (default: 100) |
|
||||
|
||||
## Security Rules
|
||||
|
||||
- **Never** output secrets (API keys, tokens) directly
|
||||
- **Always** confirm with user before executing write/delete commands
|
||||
- Prefer `--dry-run` for destructive operations
|
||||
- Use `--sanitize` for PII/content safety screening
|
||||
"#;
|
||||
|
||||
write_skill(base, "gws-shared", content)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Helper Modules
|
||||
|
||||
This directory contains "Helper" implementations that provide high-value, simplified commands for complex Google Workspace API operations.
|
||||
|
||||
## Philosophy
|
||||
|
||||
The goal of the `gws` CLI is to provide raw access to the Google Workspace APIs. However, some operations are common but complex to execute via raw API calls (e.g., sending an email, appending a row to a sheet).
|
||||
|
||||
**Helper commands should only be added if they offer "High Usefulness":**
|
||||
|
||||
* **Complex Abstraction:** Does it abstract away significant complexity (e.g., MIME encoding, complex JSON structures, multiple API calls)?
|
||||
* **Format Conversion:** Does it handle data format conversions that are tedious for the user?
|
||||
* **Not Just an Alias:** Avoid adding helpers that simply alias a single, straightforward API call.
|
||||
|
||||
## Architecture
|
||||
|
||||
Helpers are implemented using the `Helper` trait defined in `mod.rs`.
|
||||
|
||||
```rust
|
||||
pub trait Helper: Send + Sync {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
cmd: Command,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Command;
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>>;
|
||||
}
|
||||
```
|
||||
|
||||
* **`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.
|
||||
|
||||
### Catalogue
|
||||
|
||||
| Service | Command | Usage | Description | Equivalent Raw Command (Example) |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Gmail** | `+send` | `gws gmail +send ...` | Sends an email. | `gws gmail users messages send ...` |
|
||||
| **Sheets** | `+append` | `gws sheets +append ...` | Appends a row. | `gws sheets spreadsheets values append ...` |
|
||||
| **Sheets** | `+read` | `gws sheets +read ...` | Reads values. | `gws sheets spreadsheets values get ...` |
|
||||
| **Docs** | `+write` | `gws docs +write ...` | Appends text. | `gws docs documents batchUpdate ...` |
|
||||
| **Chat** | `+send` | `gws chat +send ...` | Sends message. | `gws chat spaces messages create ...` |
|
||||
| **Drive** | `+upload` | `gws drive +upload ...` | Uploads file. | `gws drive files create --upload ...` |
|
||||
| **Calendar** | `+insert` | `gws calendar +insert ...` | Creates event. | `gws calendar events insert ...` |
|
||||
| **Script** | `+push` | `gws script +push --script <ID>` | Pushes files. | `gws script projects updateContent ...` |
|
||||
| **Events** | `+subscribe` | `gws events +subscribe ...` | Subscribe & stream events. | Pub/Sub REST + Workspace Events API |
|
||||
| **Events** | `+renew` | `gws events +renew ...` | Renew subscriptions. | `gws events subscriptions reactivate ...` |
|
||||
|
||||
### Development
|
||||
|
||||
To add a new helper:
|
||||
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`).
|
||||
|
||||
## Current Helpers
|
||||
|
||||
* **Gmail**: Sending emails (abstracts RFC 2822 encoding).
|
||||
* **Sheets**: Appending rows (abstracts `ValueRange` JSON construction).
|
||||
* **Docs**: Appending text (abstracts `batchUpdate` requests).
|
||||
* **Chat**: Sending messages to spaces.
|
||||
@@ -0,0 +1,538 @@
|
||||
// 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),
|
||||
)
|
||||
.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
|
||||
|
||||
TIPS:
|
||||
Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
|
||||
For recurring events or conference links, use the raw API instead."),
|
||||
);
|
||||
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"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws calendar +agenda
|
||||
gws calendar +agenda --today
|
||||
gws calendar +agenda --week --format table
|
||||
gws calendar +agenda --days 3 --calendar 'Work'
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies events.
|
||||
Queries all calendars by default; use --calendar to filter.",
|
||||
),
|
||||
);
|
||||
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(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
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(¶ms_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(),
|
||||
)
|
||||
.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);
|
||||
|
||||
// Determine time range
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let days: u64 = if matches.get_flag("tomorrow") {
|
||||
// Start from tomorrow, 1 day
|
||||
1
|
||||
} else if matches.get_flag("week") {
|
||||
7
|
||||
} else {
|
||||
matches
|
||||
.get_one::<String>("days")
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(1)
|
||||
};
|
||||
|
||||
let (time_min_epoch, time_max_epoch) = if matches.get_flag("tomorrow") {
|
||||
// Tomorrow: start of tomorrow to end of tomorrow
|
||||
let day_seconds = 86400;
|
||||
let tomorrow_start = (now / day_seconds + 1) * day_seconds;
|
||||
(tomorrow_start, tomorrow_start + day_seconds)
|
||||
} else {
|
||||
// Start from now
|
||||
(now, now + days * 86400)
|
||||
};
|
||||
|
||||
let time_min = epoch_to_rfc3339(time_min_epoch);
|
||||
let time_max = epoch_to_rfc3339(time_max_epoch);
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
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?timeMin={}&timeMax={}&singleEvents=true&orderBy=startTime&maxResults=50",
|
||||
urlencoded(&cal.id),
|
||||
urlencoded(time_min),
|
||||
urlencoded(time_max),
|
||||
);
|
||||
|
||||
let resp = crate::client::send_with_retry(|| {
|
||||
client.get(&events_url).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 epoch_to_rfc3339(epoch: u64) -> String {
|
||||
use chrono::{TimeZone, Utc};
|
||||
Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339()
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
s.replace('%', "%25")
|
||||
.replace(' ', "%20")
|
||||
.replace('@', "%40")
|
||||
.replace('+', "%2B")
|
||||
.replace(':', "%3A")
|
||||
}
|
||||
|
||||
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 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 = json!({
|
||||
"calendarId": calendar_id
|
||||
});
|
||||
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),
|
||||
);
|
||||
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_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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
// 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(¶ms_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(),
|
||||
)
|
||||
.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) -> SendConfig {
|
||||
SendConfig {
|
||||
// We clone the strings here because ArgMatches owns the original strings,
|
||||
// and we need to pass ownership of these values to our config struct
|
||||
// to decouple it from the clap lifetime.
|
||||
space: matches.get_one::<String>("space").unwrap().clone(),
|
||||
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", "s", "--text", "t"]);
|
||||
let config = parse_send_args(&matches);
|
||||
assert_eq!(config.space, "s");
|
||||
assert_eq!(config.text, "t");
|
||||
}
|
||||
|
||||
#[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,204 @@
|
||||
// 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(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
// 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(¶ms_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(),
|
||||
)
|
||||
.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,191 @@
|
||||
// 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(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
create_method,
|
||||
None,
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
Some(file_path),
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user