From 0d2a9b35fc131665197c531751cda8ccab3f0e24 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Thu, 26 Mar 2026 08:00:15 +0900 Subject: [PATCH 01/44] feat: rebase MCP server on upstream v0.22.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upstream v0.22.1をベースにMCPサーバーを載せ直し。 - mcp_server.rsをcargo workspace構造に移動 - mail_builder APIに適合(自前MessageBuilder廃止) - execute_methodのUploadSource対応 - Gmail helper関数のpub(crate)化 - 不要ワークフロー削除、sync-upstream追加 - FORK.md/FORK.ja.md/CLAUDE.md配置 --- .changeset/mcp-helper-tools.md | 5 + .github/workflows/automation.yml | 142 -- .github/workflows/ci.yml | 90 +- .github/workflows/cla.yml | 60 - .github/workflows/coverage.yml | 54 - .github/workflows/generate-skills.yml | 119 -- .github/workflows/publish-skills.yml | 55 - .github/workflows/release-changesets.yml | 72 - .github/workflows/release.yml | 359 ----- .github/workflows/stale.yml | 36 - .github/workflows/sync-upstream.yml | 80 + CLAUDE.md | 18 + FORK.ja.md | 99 ++ FORK.md | 99 ++ .../src/helpers/gmail/mod.rs | 12 +- crates/google-workspace-cli/src/main.rs | 6 + crates/google-workspace-cli/src/mcp_server.rs | 1335 +++++++++++++++++ 17 files changed, 1649 insertions(+), 992 deletions(-) create mode 100644 .changeset/mcp-helper-tools.md delete mode 100644 .github/workflows/automation.yml delete mode 100644 .github/workflows/cla.yml delete mode 100644 .github/workflows/coverage.yml delete mode 100644 .github/workflows/generate-skills.yml delete mode 100644 .github/workflows/publish-skills.yml delete mode 100644 .github/workflows/release-changesets.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 FORK.ja.md create mode 100644 FORK.md create mode 100644 crates/google-workspace-cli/src/mcp_server.rs diff --git a/.changeset/mcp-helper-tools.md b/.changeset/mcp-helper-tools.md new file mode 100644 index 00000000..712cde78 --- /dev/null +++ b/.changeset/mcp-helper-tools.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add MCP helper tools: gmail_send convenience wrapper that reuses CLI helper logic for RFC 2822 formatting and base64 encoding diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml deleted file mode 100644 index 5eb3de95..00000000 --- a/.github/workflows/automation.yml +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Automation - -on: - push: - branches: [main] - pull_request_target: - types: [opened, synchronize, reopened] - pull_request_review: - types: [submitted] - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - format: - name: Format - if: github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - components: rustfmt - - - name: Run cargo fmt - run: cargo fmt --all - - - name: Commit and push - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet || git commit -m "style: cargo fmt" && git push - - file-labeler: - name: File Labeler - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 - with: - repo-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - sync-labels: true - - gemini-review: - name: Gemini Review - if: >- - github.event_name == 'pull_request_target' && - github.event.action == 'synchronize' - runs-on: ubuntu-latest - concurrency: - group: gemini-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - - name: Remove reviewed label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: 'gemini: reviewed', - }); - } catch (e) { - // Label not present — ignore - } - - - name: Debounce - run: sleep 60 - - - name: Trigger Gemini Code Assist review - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - script: | - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: '/gemini review', - }); - - gemini-reviewed: - name: Gemini Reviewed - if: >- - github.event_name == 'pull_request_review' && - github.event.review.user.login == 'gemini-code-assist[bot]' - runs-on: ubuntu-latest - steps: - - name: Add reviewed label if review matches HEAD - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - - if (context.payload.review.commit_id !== pr.data.head.sha) { - console.log(`Review is for ${context.payload.review.commit_id} but HEAD is ${pr.data.head.sha} — skipping label`); - return; - } - - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - labels: ['gemini: reviewed'], - }); - } catch (e) { - if (e.status === 403) { - console.log(`Token cannot add labels for this review event (${e.message}) — skipping`); - return; - } - throw e; - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e834d6e0..91c97dae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,7 +260,7 @@ jobs: build: name: Build - needs: [smoketest, changes] + needs: [build-linux, changes] if: | always() && !cancelled() && !failure() && (needs.changes.outputs.rust == 'true' || github.event_name == 'push') @@ -323,91 +323,3 @@ jobs: env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - smoketest: - name: API Smoketest - needs: build-linux - if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: gws-linux-x86_64 - path: ./bin - - - name: Make binary executable - run: chmod +x ./bin/gws - - - name: Decode credentials - env: - GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }} - run: | - if [ -z "$GOOGLE_CREDENTIALS_JSON" ]; then - echo "::error::GOOGLE_CREDENTIALS_JSON secret is not set" - exit 1 - fi - echo "$GOOGLE_CREDENTIALS_JSON" | base64 -d > /tmp/credentials.json - - - name: Smoketest — help - run: ./bin/gws --help - - - name: Smoketest — schema introspection - run: ./bin/gws schema drive.files.list | jq -e '.httpMethod' - - - name: Smoketest — Drive files list - env: - GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json - run: | - ./bin/gws drive files list \ - --params '{"pageSize": 1, "fields": "files(id,mimeType)"}' \ - | jq -e '.files' - - - name: Smoketest — Gmail messages - env: - GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json - run: | - ./bin/gws gmail users messages list \ - --params '{"userId": "me", "maxResults": 1, "fields": "messages(id)"}' \ - | jq -e '.messages' - - - name: Smoketest — Calendar events - env: - GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json - run: | - ./bin/gws calendar events list \ - --params '{"calendarId": "primary", "maxResults": 1, "fields": "kind,items(id,status)"}' \ - | jq -e '.kind' - - - name: Smoketest — Slides presentation - env: - GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json - run: | - ./bin/gws slides presentations get \ - --params '{"presentationId": "1knOKD_87JWE4qsEbO4r5O91IxTER5ybBBhOJgZ1yLFI", "fields": "presentationId,slides(objectId)"}' \ - | jq -e '.presentationId' - - - name: Smoketest — pagination - env: - GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json - run: | - LINES=$(./bin/gws drive files list \ - --params '{"pageSize": 1, "fields": "nextPageToken,files(id)"}' \ - --page-all --page-limit 2 \ - | wc -l) - if [ "$LINES" -lt 2 ]; then - echo "::error::Expected at least 2 NDJSON lines from pagination, got $LINES" - exit 1 - fi - - - name: Smoketest — error handling - run: | - if ./bin/gws fakeservice list 2>&1; then - echo "::error::Expected exit code 1 for unknown service" - exit 1 - fi - - - name: Cleanup credentials - if: always() - run: rm -f /tmp/credentials.json diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index c4a4809c..00000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: CLA - -on: - check_run: - types: [completed] - -permissions: - pull-requests: write - -jobs: - cla-label: - name: CLA Label - if: github.event.check_run.name == 'cla/google' - runs-on: ubuntu-latest - steps: - - name: Update CLA label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - script: | - const cr = context.payload.check_run; - const passed = cr.conclusion === 'success'; - - for (const pr of cr.pull_requests) { - const labels = passed - ? { add: 'cla: yes', remove: 'cla: no' } - : { add: 'cla: no', remove: 'cla: yes' }; - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [labels.add], - }); - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name: labels.remove, - }); - } catch (e) { - // Label not present — ignore - } - } diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 4e1b323c..00000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Coverage - -on: - push: - branches: [main] - paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock'] - pull_request: - branches: [main] - paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock'] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - coverage: - name: Coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - components: llvm-tools-preview - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov - with: - tool: cargo-llvm-cov - - - name: Generate code coverage - run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 - with: - files: lcov.info - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/generate-skills.yml b/.github/workflows/generate-skills.yml deleted file mode 100644 index a30f1bc3..00000000 --- a/.github/workflows/generate-skills.yml +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Generate Skills - -on: - schedule: - - cron: "0 * * * *" # Hourly — keeps skills in sync with Discovery API changes - workflow_dispatch: # Manual trigger - push: - branches-ignore: - - main # main is kept up to date by PR merges - -concurrency: - group: generate-skills-${{ github.ref_name }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - -jobs: - generate: - name: Generate and commit skills - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - # For cron/dispatch: check out main. For push: check out the branch. - ref: ${{ github.head_ref || github.ref_name }} - token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - - name: Setup sccache - uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 - - - name: Cache cargo - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 - with: - key: generate-skills-ubuntu - - - name: Generate skills - run: cargo run -- generate-skills - - - name: Check for changes - id: diff - run: | - if git diff --quiet skills/ docs/skills.md; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - # --- Cron / workflow_dispatch: open a PR against main --- - - name: Create changeset for sync PR - if: >- - steps.diff.outputs.changed == 'true' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - run: | - mkdir -p .changeset - cat > .changeset/sync-skills.md << 'EOF' - --- - "@googleworkspace/cli": patch - --- - - Sync generated skills with latest Google Discovery API specs - EOF - - - name: Create or update sync PR - if: >- - steps.diff.outputs.changed == 'true' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 - with: - token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - branch: chore/sync-skills - title: "chore: sync skills with Discovery API" - body: | - Automated PR — the Google Discovery API specs have changed and the - generated skill files are out of date. - - Created by the **Generate Skills** workflow (`generate-skills.yml`). - commit-message: "chore: regenerate skills from Discovery API" - add-paths: | - skills/ - docs/skills.md - .changeset/sync-skills.md - delete-branch: true - - # --- Push events (non-main branches): commit directly --- - - name: Commit and push if changed - if: >- - steps.diff.outputs.changed == 'true' && - github.event_name == 'push' - env: - GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - run: | - git config user.name "googleworkspace-bot" - git config user.email "googleworkspace-bot@users.noreply.github.com" - - git add skills/ docs/skills.md - git commit -m "chore: regenerate skills [skip ci]" - git push diff --git a/.github/workflows/publish-skills.yml b/.github/workflows/publish-skills.yml deleted file mode 100644 index 35214380..00000000 --- a/.github/workflows/publish-skills.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Publish OpenClaw Skills - -on: - push: - branches: [main] - paths: - - "skills/**" - - ".github/workflows/publish-skills.yml" - pull_request: - branches: [main] - paths: - - "skills/**" - - ".github/workflows/publish-skills.yml" - schedule: - - cron: "0 * * * *" # Hourly, to drip-publish past rate limits - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -jobs: - publish: - # Skip fork PRs — secrets (CLAWHUB_TOKEN) are not available - if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "20" - - - name: Install ClawHub CLI - run: npm i -g clawhub@0.7.0 - - - name: Authenticate ClawHub - env: - CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} - run: | - if [ -z "$CLAWHUB_TOKEN" ]; then - echo "::error::CLAWHUB_TOKEN secret is not set" - exit 1 - fi - clawhub login --token "$CLAWHUB_TOKEN" - - - name: Publish skills - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - clawhub sync --root skills --all --dry-run - else - clawhub sync --root skills --all - fi diff --git a/.github/workflows/release-changesets.yml b/.github/workflows/release-changesets.yml deleted file mode 100644 index 25caa4f4..00000000 --- a/.github/workflows/release-changesets.yml +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: Release (Changeset) - -on: - workflow_dispatch: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - - name: Install Nix - uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30 - with: - github_access_token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - - - uses: pnpm/action-setup@c5ba7f7862a0f64c1b1a05fbac13e0b8e86ba08c # v4 - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: '22' - cache: 'pnpm' - - - name: Install Dependencies - run: pnpm install - - - run: | - git config --global user.name "googleworkspace-bot" - git config --global user.email "googleworkspace-bot@google.com" - - - name: Create Release Pull Request or Tag - id: changesets - uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1 - with: - version: pnpm run version-sync - publish: pnpm run tag-release - commit: 'chore: release versions' - title: 'chore: release versions' - setupGitUser: false - env: - GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2cb112d9..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,359 +0,0 @@ -# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@43d14bc2b83dec42d39ecae14e916627a18bb661 # 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<> "$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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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<> "$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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: npm/ - merge-multiple: true - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - with: - node-version: '20.x' - registry-url: 'https://wombat-dressing-room.appspot.com' - - 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 }} - - publish-cargo: - needs: - - plan - - host - runs-on: "ubuntu-22.04" - if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - # Publish library crate first (CLI depends on it) - - name: Publish google-workspace - run: cargo publish --package google-workspace - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - # Wait for crates.io to index the library crate - - name: Wait for crates.io index - run: sleep 30 - - name: Publish google-workspace-cli - run: cargo publish --package google-workspace-cli - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - announce: - needs: - - plan - - host - - publish-npm - - publish-cargo - # 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') && (needs.publish-cargo.result == 'skipped' || needs.publish-cargo.result == 'success') }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 18c0b9fe..00000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -name: 'Close Stale PRs' -on: - schedule: - - cron: '30 1 * * *' - workflow_dispatch: - -permissions: - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 - with: - days-before-issue-stale: -1 - days-before-issue-close: -1 - days-before-pr-stale: 3 - days-before-pr-close: 0 - stale-pr-message: 'This PR has been inactive for 72 hours. Closing to keep the queue clean.' - close-pr-message: 'This PR was closed because it has been stalled for 72 hours. Feel free to magically reopen it if you want to continue working on it!' - exempt-pr-labels: 'keep-alive' diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..78ad0428 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,80 @@ +name: Sync upstream + +on: + schedule: + - cron: '0 0 * * 1' # 毎週月曜 UTC 0:00 + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream + run: | + git remote add upstream https://github.com/googleworkspace/cli.git + git fetch upstream + + - name: Check for new commits + id: check + run: | + COUNT=$(git rev-list HEAD..upstream/main --count) + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Upstream has $COUNT new commit(s)" + + - name: Merge upstream/main + if: steps.check.outputs.count != '0' + id: merge + run: | + if git merge upstream/main --no-edit; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + else + git merge --abort + echo "conflict=true" >> "$GITHUB_OUTPUT" + fi + + - name: Strip issue/PR number references from merge commit + if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'false' + run: | + MSG=$(git log -1 --format=%B) + CLEANED=$(echo "$MSG" | sed 's/ (#[0-9]\+)//g; s/#[0-9]\+//g') + if [ "$MSG" != "$CLEANED" ]; then + git commit --amend -m "$CLEANED" + fi + + - name: Push (no conflict) + if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'false' + run: git push origin main + + - name: Create PR (conflict) + if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'true' + run: | + BRANCH="sync-upstream/$(date +%Y%m%d)" + git checkout -b "$BRANCH" + git merge upstream/main --no-edit || true + git add -A + git commit -m "sync: merge upstream/main (conflicts need manual resolution)" + git push -u origin "$BRANCH" + gh pr create \ + --title "sync: merge upstream/main (要手動解決)" \ + --body "$(cat <<'EOF' + upstream の最新変更をマージしようとしましたが、コンフリクトが発生しました。 + 手動で解決してください。 + + MCP 機能の温存に注意してください(`src/mcp_server.rs` と `src/main.rs` の mcp エントリ)。 + EOF + )" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 2b3af2d1..90664405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,19 @@ When contributing to this repository, you must strictly follow all guidelines outlined in the AGENTS.md file. + +## Fork-specific Notes + +This is a fork of `googleworkspace/cli` that maintains MCP server support. See `FORK.md` for details. + +### Upstream Merge Checklist + +After merging upstream/main, fix MCP compilation errors: +1. `crates/google-workspace-cli/src/mcp_server.rs` — match new arguments in `executor::execute_method()` calls +2. `crates/google-workspace-cli/src/mcp_server.rs` — match new fields in Gmail helper structs +3. `crates/google-workspace-cli/src/helpers/gmail/mod.rs` — ensure `pub(crate)` visibility is not reverted to `pub(super)` +4. Run `cargo clippy -- -D warnings && cargo test` to verify + +### GitHub Actions + +- Only 3 workflows exist: `ci.yml`, `policy.yml`, `sync-upstream.yml` +- `gh workflow list` may show upstream workflows — use `gh api repos///actions/workflows` to check actual fork workflows +- `gh run list` may show upstream runs — filter with `--branch=main` for fork-specific results diff --git a/FORK.ja.md b/FORK.ja.md new file mode 100644 index 00000000..57d31c2d --- /dev/null +++ b/FORK.ja.md @@ -0,0 +1,99 @@ +# Fork: MCP サーバー機能を温存した gws + +このリポジトリは [googleworkspace/cli](https://github.com/googleworkspace/cli) のフォークです。 + +[English version](FORK.md) + +upstream が削除した **MCP(Model Context Protocol)サーバー機能** を独自にメンテナンスし、AI エージェントから Google Workspace API を直接呼び出せる状態を維持しています。 + +## upstream との差分 + +| 項目 | upstream | このフォーク | +|---|---|---| +| MCP サーバー (`gws mcp`) | 削除済み | 維持・メンテナンス中 | +| MCP helper tools (`--helpers`) | なし | `gmail_send` 等を独自実装 | +| CI/CD ワークフロー | upstream 環境依存 | 最小構成(CI + Policy + Sync) | + +### MCP サーバー + +Discovery Document から動的にツールを生成し、stdio 経由で MCP プロトコルを提供します。 + +```bash +# Gmail の MCP サーバーを起動(helper tool 付き) +gws mcp -s gmail --helpers + +# 複数サービスを同時に提供 +gws mcp -s gmail -s drive -s calendar --helpers + +# compact モード(サービスごとに1ツール) +gws mcp -s gmail --tool-mode compact +``` + +### MCP helper tools + +`--helpers` フラグで有効化される便利ツールです。Discovery API の raw tool に加え、RFC 2822 構築や base64 エンコード等の面倒な処理を自動化します。 + +| ツール名 | 説明 | +|---|---| +| `gmail_send` | メール送信。to/subject/body を渡すだけで RFC 2822 フォーマット・base64url エンコードを自動処理 | + +## インストール + +upstream の npm パッケージには MCP 機能が含まれていないため、ソースからビルドしてください。 + +```bash +# GitHub から直接インストール(推奨) +cargo install --git https://github.com/shigechika/gws-cli --locked +``` + +ローカルに clone 済みの場合は、ワーキングツリーからインストール: + +```bash +cd gws-cli +cargo install --path . +``` + +`~/.cargo/bin/gws` にバイナリがインストールされます。`cargo build --release` は `target/release/gws` にビルドするだけで `~/.cargo/bin/` は**更新されない**点に注意してください。 + +## Claude での使い方 + +**Claude Code** — `~/.claude.json` に追加: + +```json +{ + "mcpServers": { + "gws": { + "command": "gws", + "args": ["mcp", "-s", "gmail", "-s", "drive", "-s", "calendar", "--helpers"] + } + } +} +``` + +**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json`(macOS)に追加: + +```json +{ + "mcpServers": { + "gws": { + "command": "gws", + "args": ["mcp", "-s", "gmail", "-s", "drive", "-s", "calendar", "--helpers"] + } + } +} +``` + +## upstream MCP 定点観測 + +| 時期 | 出来事 | +|---|---| +| 2026-03 | `fix!: Remove MCP server mode` — upstream が breaking change として MCP サーバーを削除 | +| 2026-03 | ブランチ `fix/mcp-hyphen-tool-names` が upstream に出現 — ツール名ハイフン化で MCP 復活の兆し | +| 2026-03 | 同ブランチがマージされずに削除 — upstream での MCP 復活は見送り | + +## upstream 同期方針 + +- 毎週月曜に GitHub Actions で upstream/main を自動マージ +- コンフリクト発生時は PR を作成して手動解決 +- MCP 関連コード(`src/mcp_server.rs`、`pub(crate)` 可視性)の温存を最優先 +- upstream のコミットメッセージから `#番号` 参照を除去(クロスリファレンス防止) diff --git a/FORK.md b/FORK.md new file mode 100644 index 00000000..a1684350 --- /dev/null +++ b/FORK.md @@ -0,0 +1,99 @@ +# Fork: gws with MCP server support + +This repository is a fork of [googleworkspace/cli](https://github.com/googleworkspace/cli). + +It maintains the **MCP (Model Context Protocol) server** that upstream removed, allowing AI agents to call Google Workspace APIs directly. + +[日本語版はこちら](FORK.ja.md) + +## Differences from upstream + +| Feature | upstream | This fork | +|---|---|---| +| MCP server (`gws mcp`) | Removed | Maintained | +| MCP helper tools (`--helpers`) | N/A | `gmail_send` and more | +| CI/CD workflows | Upstream-specific | Minimal (CI + Policy + Sync) | + +### MCP server + +Dynamically generates tools from Discovery Documents and serves them via the MCP protocol over stdio. + +```bash +# Start MCP server for Gmail with helper tools +gws mcp -s gmail --helpers + +# Serve multiple services +gws mcp -s gmail -s drive -s calendar --helpers + +# Compact mode (one tool per service) +gws mcp -s gmail --tool-mode compact +``` + +### MCP helper tools + +Enabled with the `--helpers` flag. These provide high-level operations on top of the raw Discovery API tools, automating tedious tasks like RFC 2822 formatting and base64url encoding. + +| Tool | Description | +|---|---| +| `gmail_send` | Send email. Just pass to/subject/body — RFC 2822 formatting and base64url encoding are handled automatically | + +## Installation + +The upstream npm package does not include MCP support. Build from source: + +```bash +# Install directly from GitHub (recommended) +cargo install --git https://github.com/shigechika/gws-cli --locked +``` + +If you cloned the repository locally, install from the working tree: + +```bash +cd gws-cli +cargo install --path . +``` + +This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release` only builds to `target/release/gws` and does **not** update `~/.cargo/bin/`. + +## Usage with Claude + +**Claude Code** — add to `~/.claude.json`: + +```json +{ + "mcpServers": { + "gws": { + "command": "gws", + "args": ["mcp", "-s", "gmail", "-s", "drive", "-s", "calendar", "--helpers"] + } + } +} +``` + +**Claude Desktop** — add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): + +```json +{ + "mcpServers": { + "gws": { + "command": "gws", + "args": ["mcp", "-s", "gmail", "-s", "drive", "-s", "calendar", "--helpers"] + } + } +} +``` + +## Upstream MCP timeline + +| Date | Event | +|---|---| +| 2026-03 | `fix!: Remove MCP server mode` — MCP server removed from upstream as a breaking change | +| 2026-03 | Branch `fix/mcp-hyphen-tool-names` appeared in upstream — looked like MCP might return with hyphenated tool names | +| 2026-03 | Branch `fix/mcp-hyphen-tool-names` deleted without being merged — MCP remains absent from upstream | + +## Upstream sync policy + +- Weekly auto-merge from upstream/main via GitHub Actions (every Monday) +- Conflicts trigger a PR for manual resolution +- MCP-related code (`src/mcp_server.rs`, `pub(crate)` visibility) is preserved as top priority +- Issue/PR number references (`#123`) are stripped from upstream commit messages to prevent cross-references diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index eed175a6..f82b6aaa 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -62,7 +62,7 @@ fn sanitize_control_chars(s: &str) -> String { /// A parsed RFC 5322 mailbox: optional display name + email address. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] -pub(super) struct Mailbox { +pub(crate) struct Mailbox { pub name: Option, pub email: String, } @@ -133,7 +133,7 @@ pub(super) fn to_mb_address(mailbox: &Mailbox) -> MbAddress<'_> { } /// Convert a slice of `Mailbox` to a `mail_builder::Address` (list). -pub(super) fn to_mb_address_list(mailboxes: &[Mailbox]) -> MbAddress<'_> { +pub(crate) fn to_mb_address_list(mailboxes: &[Mailbox]) -> MbAddress<'_> { MbAddress::new_list(mailboxes.iter().map(to_mb_address).collect()) } @@ -1165,7 +1165,7 @@ pub(super) fn set_threading_headers<'x>( } /// Apply optional From, CC, and BCC headers to a `MessageBuilder`. -pub(super) fn apply_optional_headers<'x>( +pub(crate) fn apply_optional_headers<'x>( mut mb: mail_builder::MessageBuilder<'x>, from: Option<&'x [Mailbox]>, cc: Option<&'x [Mailbox]>, @@ -1189,7 +1189,7 @@ pub(super) fn apply_optional_headers<'x>( /// `multipart/related` container so `cid:` references render correctly. Gmail's API /// rewrites `Content-Disposition: inline` to `attachment` when parts sit in /// `multipart/mixed`, so the explicit `multipart/related` structure is required. -pub(super) fn finalize_message( +pub(crate) fn finalize_message( mb: mail_builder::MessageBuilder<'_>, body: impl Into, html: bool, @@ -1282,7 +1282,7 @@ const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; /// from the Gmail API). mail-builder handles RFC 2231 encoding for non-ASCII /// filenames in the Content-Disposition header. #[derive(Debug)] -pub(super) struct Attachment { +pub(crate) struct Attachment { pub filename: String, pub content_type: String, pub data: Vec, @@ -1402,7 +1402,7 @@ fn resolve_draft_method( } /// Resolve either `users.drafts.create` or `users.messages.send` based on the draft flag. -pub(super) fn resolve_mail_method( +pub(crate) fn resolve_mail_method( doc: &crate::discovery::RestDescription, draft: bool, ) -> Result<&crate::discovery::RestMethod, GwsError> { diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..16ca9cf8 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -32,6 +32,7 @@ mod fs_util; mod generate_skills; mod helpers; mod logging; +mod mcp_server; mod oauth_config; mod output; mod schema; @@ -138,6 +139,11 @@ async fn run() -> Result<(), GwsError> { return auth_commands::handle_auth_command(&auth_args).await; } + // Handle the `mcp` command + if first_arg == "mcp" { + return mcp_server::start(&args[1..]).await; + } + // Parse service name and optional version override let (api_name, version) = parse_service_and_version(&args, &first_arg)?; diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs new file mode 100644 index 00000000..0b38a7e6 --- /dev/null +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -0,0 +1,1335 @@ +// 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. + +//! Model Context Protocol (MCP) server implementation. +//! Provides a stdio JSON-RPC server exposing Google Workspace APIs as MCP tools. + +use crate::discovery::RestResource; +use crate::error::GwsError; +use crate::services; +use clap::{Arg, Command}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ToolMode { + Full, + Compact, +} + +#[derive(Debug, Clone)] +struct ServerConfig { + services: Vec, + workflows: bool, + helpers: bool, + tool_mode: ToolMode, +} + +fn build_mcp_cli() -> Command { + Command::new("mcp") + .about("Starts the MCP server over stdio") + .arg( + Arg::new("services") + .long("services") + .short('s') + .help("Comma separated list of services to expose (e.g., drive,gmail,all)") + .default_value(""), + ) + .arg( + Arg::new("workflows") + .long("workflows") + .short('w') + .action(clap::ArgAction::SetTrue) + .help("Expose workflows as tools"), + ) + .arg( + Arg::new("helpers") + .long("helpers") + .short('e') + .action(clap::ArgAction::SetTrue) + .help("Expose service-specific helpers as tools"), + ) + .arg( + Arg::new("tool-mode") + .long("tool-mode") + .value_parser(["compact", "full"]) + .default_value("full") + .help("Tool granularity: 'compact' (1 tool/service + discover) or 'full' (1 tool/method)"), + ) +} + +pub async fn start(args: &[String]) -> Result<(), GwsError> { + let matches = build_mcp_cli().get_matches_from(args); + let tool_mode = match matches.get_one::("tool-mode").map(|s| s.as_str()) { + Some("compact") => ToolMode::Compact, + _ => ToolMode::Full, + }; + let mut config = ServerConfig { + services: Vec::new(), + workflows: matches.get_flag("workflows"), + helpers: matches.get_flag("helpers"), + tool_mode, + }; + + let svc_str = matches.get_one::("services").unwrap(); + if !svc_str.is_empty() { + if svc_str == "all" { + config.services = services::SERVICES + .iter() + .map(|s| s.aliases[0].to_string()) + .collect(); + } else { + config.services = svc_str.split(',').map(|s| s.trim().to_string()).collect(); + } + } + + if config.services.is_empty() { + eprintln!("[gws mcp] Warning: No services configured. Zero tools will be exposed."); + eprintln!("[gws mcp] Re-run with: gws mcp -s (e.g., -s drive,gmail,calendar)"); + eprintln!("[gws mcp] Use -s all to expose all available services."); + } else { + eprintln!( + "[gws mcp] Starting with services: {}", + config.services.join(", ") + ); + eprintln!("[gws mcp] Tool mode: {:?}", config.tool_mode); + } + + let mut stdin = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = tokio::io::stdout(); + + let mut tools_cache = None; + + while let Ok(Some(line)) = stdin.next_line().await { + if line.trim().is_empty() { + continue; + } + + match serde_json::from_str::(&line) { + Ok(req) => { + let is_notification = req.get("id").is_none(); + let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = req.get("params").cloned().unwrap_or_else(|| json!({})); + + let result = handle_request(method, ¶ms, &config, &mut tools_cache).await; + + if !is_notification { + let id = req.get("id").unwrap(); + let response = match result { + Ok(res) => json!({ + "jsonrpc": "2.0", + "id": id, + "result": res + }), + Err(e) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32603, + "message": e.to_string() + } + }), + }; + + let mut out = match serde_json::to_string(&response) { + Ok(s) => s, + Err(e) => { + eprintln!("[gws mcp] Failed to serialize response: {e}"); + continue; + } + }; + out.push('\n'); + let _ = stdout.write_all(out.as_bytes()).await; + let _ = stdout.flush().await; + } + } + Err(_) => { + let response = json!({ + "jsonrpc": "2.0", + "id": Value::Null, + "error": { + "code": -32700, + "message": "Parse error" + } + }); + let mut out = match serde_json::to_string(&response) { + Ok(s) => s, + Err(e) => { + eprintln!("[gws mcp] Failed to serialize error response: {e}"); + continue; + } + }; + out.push('\n'); + let _ = stdout.write_all(out.as_bytes()).await; + let _ = stdout.flush().await; + } + } + } + + Ok(()) +} + +async fn handle_request( + method: &str, + params: &Value, + config: &ServerConfig, + tools_cache: &mut Option>, +) -> Result { + match method { + "initialize" => Ok(json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": "gws-mcp", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "tools": {} + } + })), + "notifications/initialized" => Ok(json!({})), + "tools/list" => { + if tools_cache.is_none() { + *tools_cache = Some(build_tools_list(config).await?); + } + Ok(json!({ + "tools": tools_cache.as_ref().unwrap() + })) + } + "tools/call" => { + match handle_tools_call(params, config).await { + Ok(val) => Ok(val), + Err(e) => Ok(json!({ + "content": [{ "type": "text", "text": e.to_string() }], + "isError": true + })), + } + } + _ => Err(GwsError::Validation(format!( + "Method not supported: {}", + method + ))), + } +} + +async fn build_tools_list(config: &ServerConfig) -> Result, GwsError> { + if config.tool_mode == ToolMode::Compact { + return build_compact_tools_list(config).await; + } + + let mut tools = Vec::new(); + + for svc_name in &config.services { + let (api_name, version) = + crate::parse_service_and_version(&[svc_name.to_string()], svc_name)?; + if let Ok(doc) = crate::discovery::fetch_discovery_document(&api_name, &version).await { + walk_resources(&doc.name, &doc.resources, &mut tools); + } else { + eprintln!("[gws mcp] Warning: Failed to load discovery document for service '{}'. It will not be available as a tool.", svc_name); + } + } + + if config.helpers { + append_helper_tools(&config.services, &mut tools); + } + + if config.workflows { + append_workflow_tools(&mut tools); + } + + Ok(tools) +} + +async fn build_compact_tools_list(config: &ServerConfig) -> Result, GwsError> { + let mut tools = Vec::new(); + + for svc_name in &config.services { + let (api_name, version) = + crate::parse_service_and_version(&[svc_name.to_string()], svc_name)?; + + let description = if let Ok(doc) = + crate::discovery::fetch_discovery_document(&api_name, &version).await + { + let mut resource_names = Vec::new(); + collect_resource_paths(&doc.resources, "", &mut resource_names); + resource_names.sort(); + let svc_entry = services::SERVICES + .iter() + .find(|e| e.aliases.contains(&svc_name.as_str())); + let desc = svc_entry.map(|e| e.description).unwrap_or("Google API"); + if resource_names.is_empty() { + desc.to_string() + } else { + let names_str: Vec<&str> = resource_names.iter().map(|s| s.as_str()).collect(); + format!("{}. Resources: {}", desc, names_str.join(", ")) + } + } else { + eprintln!( + "[gws mcp] Warning: Failed to load discovery document for '{}'. Tool will have minimal description.", + svc_name + ); + format!("Google Workspace API: {}", svc_name) + }; + + tools.push(json!({ + "name": svc_name, + "description": description, + "inputSchema": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "description": "Resource name (e.g., files, permissions)" + }, + "method": { + "type": "string", + "description": "Method name (e.g., list, get, create)" + }, + "params": { + "type": "object", + "description": "Query or path parameters" + }, + "body": { + "type": "object", + "description": "Request body" + }, + "upload": { + "type": "string", + "description": "Local file path to upload" + }, + "page_all": { + "type": "boolean", + "description": "Auto-paginate, returning all pages" + } + }, + "required": ["resource", "method"] + } + })); + } + + tools.push(json!({ + "name": "gws_discover", + "description": "Query available resources, methods, and parameter schemas for any enabled service. Call with service only to list resources; add resource to list methods; add method to get full parameter schema.", + "inputSchema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name (e.g., drive, gmail)" + }, + "resource": { + "type": "string", + "description": "Resource name to list methods for" + }, + "method": { + "type": "string", + "description": "Method name to get full parameter schema" + } + }, + "required": ["service"] + } + })); + + if config.helpers { + append_helper_tools(&config.services, &mut tools); + } + + if config.workflows { + append_workflow_tools(&mut tools); + } + + Ok(tools) +} + +fn append_workflow_tools(tools: &mut Vec) { + tools.push(json!({ + "name": "workflow_standup_report", + "description": "Today's meetings + open tasks as a standup summary", + "inputSchema": { + "type": "object", + "properties": { + "format": { "type": "string", "description": "Output format: json, table, yaml, csv" } + } + } + })); + tools.push(json!({ + "name": "workflow_meeting_prep", + "description": "Prepare for your next meeting: agenda, attendees, and linked docs", + "inputSchema": { + "type": "object", + "properties": { + "calendar": { "type": "string", "description": "Calendar ID (default: primary)" } + } + } + })); + tools.push(json!({ + "name": "workflow_email_to_task", + "description": "Convert a Gmail message into a Google Tasks entry", + "inputSchema": { + "type": "object", + "properties": { + "message_id": { "type": "string", "description": "Gmail message ID" }, + "tasklist": { "type": "string", "description": "Task list ID" } + }, + "required": ["message_id"] + } + })); + tools.push(json!({ + "name": "workflow_weekly_digest", + "description": "Weekly summary: this week's meetings + unread email count", + "inputSchema": { + "type": "object", + "properties": { + "format": { "type": "string", "description": "Output format" } + } + } + })); + tools.push(json!({ + "name": "workflow_file_announce", + "description": "Announce a Drive file in a Chat space", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "space": { "type": "string", "description": "Chat space name" }, + "message": { "type": "string", "description": "Custom message" } + }, + "required": ["file_id", "space"] + } + })); +} + +fn append_helper_tools(services: &[String], tools: &mut Vec) { + if services.iter().any(|s| s == "gmail") { + tools.push(json!({ + "name": "gmail_send", + "description": "Send an email with plain text body. Handles RFC 2822 formatting and base64 encoding automatically — no need to construct raw messages manually.", + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Recipient email address(es), comma-separated" + }, + "subject": { + "type": "string", + "description": "Email subject" + }, + "body": { + "type": "string", + "description": "Email body (plain text)" + }, + "cc": { + "type": "string", + "description": "CC email address(es), comma-separated" + }, + "bcc": { + "type": "string", + "description": "BCC email address(es), comma-separated" + } + }, + "required": ["to", "subject", "body"] + } + })); + } +} + +async fn handle_gmail_send(arguments: &Value) -> Result { + let to = arguments + .get("to") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'to' parameter".to_string()))?; + let subject = arguments + .get("subject") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'subject' parameter".to_string()))?; + let body_text = arguments + .get("body") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'body' parameter".to_string()))?; + let cc_str = arguments + .get("cc") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()); + let bcc_str = arguments + .get("bcc") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()); + + // Build RFC 2822 message using mail_builder (via shared helpers) + let to_mailboxes = crate::helpers::gmail::Mailbox::parse_list(to); + if to_mailboxes.is_empty() { + return Err(GwsError::Validation( + "'to' must specify at least one recipient".to_string(), + )); + } + let cc_mailboxes = cc_str.map(crate::helpers::gmail::Mailbox::parse_list); + let bcc_mailboxes = bcc_str.map(crate::helpers::gmail::Mailbox::parse_list); + + let mb = mail_builder::MessageBuilder::new() + .to(crate::helpers::gmail::to_mb_address_list(&to_mailboxes)) + .subject(subject); + + let mb = crate::helpers::gmail::apply_optional_headers( + mb, + None, + cc_mailboxes.as_deref(), + bcc_mailboxes.as_deref(), + ); + + let raw_message = crate::helpers::gmail::finalize_message(mb, body_text, false, &[])?; + + // Fetch Gmail discovery doc and resolve the send method + let (api_name, version) = + crate::parse_service_and_version(&["gmail".to_string()], "gmail")?; + let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; + let send_method = crate::helpers::gmail::resolve_mail_method(&doc, false)?; + + let params = json!({ "userId": "me" }); + let params_str = params.to_string(); + + let scopes: Vec<&str> = crate::select_scope(&send_method.scopes).into_iter().collect(); + let (token, auth_method) = match crate::auth::get_token(&scopes).await { + Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), + Err(e) => { + return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))); + } + }; + + let pagination = crate::executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + }; + + let result = crate::executor::execute_method( + &doc, + send_method, + Some(¶ms_str), + None, + token.as_deref(), + auth_method, + None, + Some(crate::executor::UploadSource::Bytes { + data: raw_message.as_bytes(), + content_type: "message/rfc822", + }), + false, + &pagination, + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + true, + ) + .await?; + + let text_content = match result { + Some(val) => serde_json::to_string_pretty(&val).unwrap_or_else(|_| "[]".to_string()), + None => "Email sent successfully.".to_string(), + }; + + Ok(json!({ + "content": [{ "type": "text", "text": text_content }], + "isError": false + })) +} + +fn walk_resources(prefix: &str, resources: &HashMap, tools: &mut Vec) { + for (res_name, res) in resources { + let new_prefix = format!("{}_{}", prefix, res_name); + + for (method_name, method) in &res.methods { + let tool_name = format!("{}_{}", new_prefix, method_name); + let mut description = method.description.clone().unwrap_or_default(); + if description.is_empty() { + description = format!("Execute the {} Google API method", tool_name); + } + + let mut properties = serde_json::Map::new(); + properties.insert( + "params".to_string(), + json!({ + "type": "object", + "description": "Query or path parameters (e.g. fileId, q, pageSize)" + }), + ); + if method.request.is_some() { + properties.insert( + "body".to_string(), + json!({ + "type": "object", + "description": "Request body API object" + }), + ); + } + if method.supports_media_upload { + properties.insert( + "upload".to_string(), + json!({ + "type": "string", + "description": "Local file path to upload as media content" + }), + ); + } + if method.parameters.contains_key("pageToken") { + properties.insert( + "page_all".to_string(), + json!({ + "type": "boolean", + "description": "Auto-paginate, returning all pages" + }), + ); + } + let input_schema = json!({ + "type": "object", + "properties": properties + }); + + tools.push(json!({ + "name": tool_name, + "description": description, + "inputSchema": input_schema + })); + } + + if !res.resources.is_empty() { + walk_resources(&new_prefix, &res.resources, tools); + } + } +} + +async fn handle_discover(arguments: &Value, config: &ServerConfig) -> Result { + let service = arguments + .get("service") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'service' in gws_discover".to_string()))?; + + if !config.services.contains(&service.to_string()) { + return Err(GwsError::Validation(format!( + "Service '{}' is not enabled. Enabled: {}", + service, + config.services.join(", ") + ))); + } + + let (api_name, version) = crate::parse_service_and_version(&[service.to_string()], service)?; + let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; + + let resource_name = arguments.get("resource").and_then(|v| v.as_str()); + let method_name = arguments.get("method").and_then(|v| v.as_str()); + + let result = match (resource_name, method_name) { + (None, _) => { + let mut resource_entries = Vec::new(); + collect_resource_entries(&doc.resources, "", &mut resource_entries); + json!({ "service": service, "resources": resource_entries }) + } + (Some(res), None) => { + let mut all_paths = Vec::new(); + collect_resource_paths(&doc.resources, "", &mut all_paths); + let resource = find_resource(&doc.resources, res).ok_or_else(|| { + GwsError::Validation(format!( + "Resource '{}' not found in {}. Available: {}", + res, + service, + all_paths.join(", ") + )) + })?; + let methods: Vec = resource + .methods + .iter() + .map(|(name, m)| { + json!({ + "name": name, + "httpMethod": m.http_method, + "description": m.description.as_deref().unwrap_or("") + }) + }) + .collect(); + let sub_resources: Vec<&str> = resource.resources.keys().map(|s| s.as_str()).collect(); + let mut result = json!({ "service": service, "resource": res, "methods": methods }); + if !sub_resources.is_empty() { + result["subResources"] = json!(sub_resources); + } + result + } + (Some(res), Some(meth)) => { + let resource = find_resource(&doc.resources, res).ok_or_else(|| { + let mut all_paths = Vec::new(); + collect_resource_paths(&doc.resources, "", &mut all_paths); + GwsError::Validation(format!( + "Resource '{}' not found in {}. Available: {}", + res, + service, + all_paths.join(", ") + )) + })?; + let method = resource.methods.get(meth).ok_or_else(|| { + GwsError::Validation(format!( + "Method '{}' not found in {}.{}. Available: {}", + meth, + service, + res, + resource + .methods + .keys() + .cloned() + .collect::>() + .join(", ") + )) + })?; + let params: Vec = method + .parameters + .iter() + .map(|(name, p)| { + json!({ + "name": name, + "type": p.param_type.as_deref().unwrap_or("string"), + "required": p.required, + "location": p.location.as_deref().unwrap_or("query"), + "description": p.description.as_deref().unwrap_or("") + }) + }) + .collect(); + json!({ + "service": service, + "resource": res, + "method": meth, + "httpMethod": method.http_method, + "description": method.description.as_deref().unwrap_or(""), + "parameters": params, + "supportsMediaUpload": method.supports_media_upload, + "supportsMediaDownload": method.supports_media_download + }) + } + }; + + Ok(json!({ + "content": [{ "type": "text", "text": serde_json::to_string_pretty(&result).unwrap_or_default() }], + "isError": false + })) +} + +fn collect_resource_paths( + resources: &HashMap, + prefix: &str, + out: &mut Vec, +) { + for (name, res) in resources { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{}.{}", prefix, name) + }; + out.push(path.clone()); + if !res.resources.is_empty() { + collect_resource_paths(&res.resources, &path, out); + } + } +} + +fn collect_resource_entries( + resources: &HashMap, + prefix: &str, + out: &mut Vec, +) { + for (name, res) in resources { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{}.{}", prefix, name) + }; + let methods: Vec<&str> = res.methods.keys().map(|s| s.as_str()).collect(); + if !methods.is_empty() { + out.push(json!({ + "name": path.clone(), + "methods": methods + })); + } + if !res.resources.is_empty() { + collect_resource_entries(&res.resources, &path, out); + } + } +} + +fn find_resource<'a>( + resources: &'a HashMap, + path: &str, +) -> Option<&'a RestResource> { + let mut segments = path.split('.'); + let first_segment = segments.next()?; + let mut current_res = resources.get(first_segment)?; + for segment in segments { + current_res = current_res.resources.get(segment)?; + } + Some(current_res) +} + +async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result { + let tool_name = params + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'name' in tools/call".to_string()))?; + + let default_args = json!({}); + let arguments = params.get("arguments").unwrap_or(&default_args); + + if tool_name.starts_with("workflow_") { + return Err(GwsError::Other(anyhow::anyhow!( + "Workflows are not yet fully implemented via MCP" + ))); + } + + if tool_name == "gws_discover" { + return handle_discover(arguments, config).await; + } + + if tool_name == "gmail_send" { + if !config.helpers { + return Err(GwsError::Validation( + "Helper tools are not enabled. Re-run with --helpers flag.".to_string(), + )); + } + return handle_gmail_send(arguments).await; + } + + // Compact mode + if config.tool_mode == ToolMode::Compact { + let resource_path = arguments + .get("resource") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'resource' argument".to_string()))?; + let method_name = arguments + .get("method") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("Missing 'method' argument".to_string()))?; + + let svc_alias = tool_name; + if !config.services.contains(&svc_alias.to_string()) { + return Err(GwsError::Validation(format!( + "Service '{}' is not enabled in this MCP session", + svc_alias + ))); + } + + let (api_name, version) = + crate::parse_service_and_version(&[svc_alias.to_string()], svc_alias)?; + let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; + + let resource = find_resource(&doc.resources, resource_path).ok_or_else(|| { + GwsError::Validation(format!( + "Resource '{}' not found in {}", + resource_path, svc_alias + )) + })?; + + let method = resource.methods.get(method_name).ok_or_else(|| { + GwsError::Validation(format!( + "Method '{}' not found in {}.{}", + method_name, svc_alias, resource_path + )) + })?; + + return execute_mcp_method(&doc, method, arguments).await; + } + + // Full mode + let parts: Vec<&str> = tool_name.split('_').collect(); + if parts.len() < 3 { + return Err(GwsError::Validation(format!( + "Invalid API tool name: {}", + tool_name + ))); + } + + let svc_alias = parts[0]; + + if !config.services.contains(&svc_alias.to_string()) { + return Err(GwsError::Validation(format!( + "Service '{}' is not enabled in this MCP session", + svc_alias + ))); + } + + let (api_name, version) = + crate::parse_service_and_version(&[svc_alias.to_string()], svc_alias)?; + let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; + + let mut current_resources = &doc.resources; + let mut current_res = None; + + for res_name in &parts[1..parts.len() - 1] { + if let Some(res) = current_resources.get(*res_name) { + current_res = Some(res); + current_resources = &res.resources; + } else { + return Err(GwsError::Validation(format!( + "Resource '{}' not found in Discovery Document", + res_name + ))); + } + } + + let method_name = parts.last().unwrap(); + let method = if let Some(res) = current_res { + res.methods + .get(*method_name) + .ok_or_else(|| GwsError::Validation(format!("Method '{}' not found", method_name)))? + } else { + return Err(GwsError::Validation("Resource not found".to_string())); + }; + + execute_mcp_method(&doc, method, arguments).await +} + +async fn execute_mcp_method( + doc: &crate::discovery::RestDescription, + method: &crate::discovery::RestMethod, + arguments: &Value, +) -> Result { + let params_json_val = arguments.get("params"); + let params_str = params_json_val + .map(serde_json::to_string) + .transpose() + .map_err(|e| GwsError::Validation(format!("Failed to serialize params: {e}")))?; + + let body_json_val = arguments + .get("body") + .filter(|v| !v.as_object().is_some_and(|m| m.is_empty())); + let body_str = body_json_val + .map(serde_json::to_string) + .transpose() + .map_err(|e| GwsError::Validation(format!("Failed to serialize body: {e}")))?; + + let upload_source = if let Some(raw) = arguments + .get("upload") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + let p = std::path::Path::new(raw); + if p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) { + return Err(GwsError::Validation(format!( + "Upload path '{}' is not allowed. Paths must be relative and within the current directory.", + raw + ))); + } + Some(crate::executor::UploadSource::File { + path: raw, + content_type: None, + }) + } else { + None + }; + + let page_all = arguments + .get("page_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let pagination = crate::executor::PaginationConfig { + page_all, + page_limit: 100, + page_delay_ms: 100, + }; + + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); + let (token, auth_method) = match crate::auth::get_token(&scopes).await { + Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), + Err(e) => { + eprintln!( + "[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}" + ); + (None, crate::executor::AuthMethod::None) + } + }; + + let result = crate::executor::execute_method( + doc, + method, + params_str.as_deref(), + body_str.as_deref(), + token.as_deref(), + auth_method, + None, + upload_source, + false, + &pagination, + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + true, + ) + .await?; + + let text_content = match result { + Some(val) => serde_json::to_string_pretty(&val).unwrap_or_else(|_| "[]".to_string()), + None => "Execution completed with no output.".to_string(), + }; + + Ok(json!({ + "content": [ + { + "type": "text", + "text": text_content + } + ], + "isError": false + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use std::collections::HashMap; + + fn mock_config_compact(services: Vec<&str>) -> ServerConfig { + ServerConfig { + services: services.into_iter().map(String::from).collect(), + workflows: false, + helpers: false, + tool_mode: ToolMode::Compact, + } + } + + fn mock_doc() -> RestDescription { + let mut params = HashMap::new(); + params.insert( + "fileId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + required: true, + location: Some("path".to_string()), + description: Some("The ID of the file".to_string()), + ..Default::default() + }, + ); + params.insert( + "fields".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + required: false, + location: Some("query".to_string()), + description: Some("Selector specifying fields".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + description: Some("Lists files".to_string()), + ..Default::default() + }, + ); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "files/{fileId}".to_string(), + description: Some("Gets a file".to_string()), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + ..Default::default() + }, + ); + + RestDescription { + name: "drive".to_string(), + resources, + ..Default::default() + } + } + + fn mock_nested_doc() -> RestDescription { + let mut msg_methods = HashMap::new(); + msg_methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "messages".to_string(), + description: Some("Lists messages".to_string()), + ..Default::default() + }, + ); + msg_methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "messages/{id}".to_string(), + description: Some("Gets a message".to_string()), + ..Default::default() + }, + ); + let messages = RestResource { + methods: msg_methods, + ..Default::default() + }; + + let mut thread_methods = HashMap::new(); + thread_methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "threads".to_string(), + ..Default::default() + }, + ); + let threads = RestResource { + methods: thread_methods, + ..Default::default() + }; + + let mut user_methods = HashMap::new(); + user_methods.insert( + "getProfile".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "users/{userId}/profile".to_string(), + ..Default::default() + }, + ); + + let mut sub_resources = HashMap::new(); + sub_resources.insert("messages".to_string(), messages); + sub_resources.insert("threads".to_string(), threads); + + let users = RestResource { + methods: user_methods, + resources: sub_resources, + }; + + let mut resources = HashMap::new(); + resources.insert("users".to_string(), users); + + RestDescription { + name: "gmail".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_find_resource_top_level() { + let doc = mock_doc(); + let res = find_resource(&doc.resources, "files"); + assert!(res.is_some()); + assert!(res.unwrap().methods.contains_key("list")); + } + + #[test] + fn test_find_resource_not_found() { + let doc = mock_doc(); + assert!(find_resource(&doc.resources, "missing").is_none()); + } + + #[test] + fn test_find_resource_nested_dot_path() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "permissions".to_string(), + ..Default::default() + }, + ); + let inner = RestResource { + methods: inner_methods, + ..Default::default() + }; + let mut sub_resources = HashMap::new(); + sub_resources.insert("permissions".to_string(), inner); + + let outer = RestResource { + resources: sub_resources, + ..Default::default() + }; + let mut top = HashMap::new(); + top.insert("files".to_string(), outer); + + let res = find_resource(&top, "files.permissions"); + assert!(res.is_some()); + assert!(res.unwrap().methods.contains_key("create")); + } + + #[test] + fn test_collect_resource_paths_flat() { + let doc = mock_doc(); + let mut paths = Vec::new(); + collect_resource_paths(&doc.resources, "", &mut paths); + paths.sort(); + assert_eq!(paths, vec!["files"]); + } + + #[test] + fn test_collect_resource_paths_nested() { + let doc = mock_nested_doc(); + let mut paths = Vec::new(); + collect_resource_paths(&doc.resources, "", &mut paths); + paths.sort(); + assert!(paths.contains(&"users".to_string())); + assert!(paths.contains(&"users.messages".to_string())); + } + + #[test] + fn test_collect_resource_entries_includes_nested() { + let doc = mock_nested_doc(); + let mut entries = Vec::new(); + collect_resource_entries(&doc.resources, "", &mut entries); + let names: Vec<&str> = entries.iter().filter_map(|e| e["name"].as_str()).collect(); + assert!(names.contains(&"users")); + assert!(names.contains(&"users.messages")); + } + + #[tokio::test] + async fn test_discover_service_not_enabled() { + let config = mock_config_compact(vec!["gmail"]); + let args = json!({"service": "drive"}); + + let result = handle_discover(&args, &config).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("not enabled")); + } + + #[tokio::test] + async fn test_discover_missing_service_arg() { + let config = mock_config_compact(vec!["drive"]); + let args = json!({}); + + let result = handle_discover(&args, &config).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Missing 'service'")); + } + + #[test] + fn test_tool_mode_enum_equality() { + assert_eq!(ToolMode::Compact, ToolMode::Compact); + assert_ne!(ToolMode::Compact, ToolMode::Full); + } + + #[test] + fn test_cli_tool_mode_default_is_full() { + let cli = build_mcp_cli(); + let matches = cli.get_matches_from(vec!["mcp"]); + let mode = matches.get_one::("tool-mode").unwrap(); + assert_eq!(mode, "full"); + } + + #[test] + fn test_cli_tool_mode_compact() { + let cli = build_mcp_cli(); + let matches = cli.get_matches_from(vec!["mcp", "--tool-mode", "compact"]); + let mode = matches.get_one::("tool-mode").unwrap(); + assert_eq!(mode, "compact"); + } + + #[test] + fn test_cli_tool_mode_invalid_rejected() { + let cli = build_mcp_cli(); + let result = cli.try_get_matches_from(vec!["mcp", "--tool-mode", "invalid"]); + assert!(result.is_err()); + } + + #[test] + fn test_append_workflow_tools_adds_five() { + let mut tools = Vec::new(); + append_workflow_tools(&mut tools); + assert_eq!(tools.len(), 5); + assert_eq!(tools[0]["name"], "workflow_standup_report"); + assert_eq!(tools[4]["name"], "workflow_file_announce"); + } + + #[test] + fn test_append_helper_tools_gmail_adds_send() { + let services = vec!["gmail".to_string()]; + let mut tools = Vec::new(); + append_helper_tools(&services, &mut tools); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "gmail_send"); + + let schema = &tools[0]["inputSchema"]; + let required = schema["required"].as_array().unwrap(); + let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_strs.contains(&"to")); + assert!(required_strs.contains(&"subject")); + assert!(required_strs.contains(&"body")); + + let props = schema["properties"].as_object().unwrap(); + assert!(props.contains_key("cc")); + assert!(props.contains_key("bcc")); + } + + #[test] + fn test_append_helper_tools_no_gmail_adds_nothing() { + let services = vec!["drive".to_string(), "calendar".to_string()]; + let mut tools = Vec::new(); + append_helper_tools(&services, &mut tools); + assert!(tools.is_empty()); + } + + #[tokio::test] + async fn test_handle_gmail_send_missing_to() { + let args = json!({"subject": "Hi", "body": "Hello"}); + let result = handle_gmail_send(&args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("'to'")); + } + + #[tokio::test] + async fn test_handle_gmail_send_missing_subject() { + let args = json!({"to": "a@b.com", "body": "Hello"}); + let result = handle_gmail_send(&args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("'subject'")); + } + + #[tokio::test] + async fn test_handle_gmail_send_missing_body() { + let args = json!({"to": "a@b.com", "subject": "Hi"}); + let result = handle_gmail_send(&args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("'body'")); + } + + #[tokio::test] + async fn test_gmail_send_rejected_when_helpers_disabled() { + let config = ServerConfig { + services: vec!["gmail".to_string()], + workflows: false, + helpers: false, + tool_mode: ToolMode::Full, + }; + let params = json!({ + "name": "gmail_send", + "arguments": {"to": "a@b.com", "subject": "Hi", "body": "Hello"} + }); + let result = handle_tools_call(¶ms, &config).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("--helpers")); + } +} From 7b9df11055636051fce94c8279ca4b6f261c96f9 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Thu, 26 Mar 2026 08:17:17 +0900 Subject: [PATCH 02/44] =?UTF-8?q?docs:=20cargo=20workspace=E6=A7=8B?= =?UTF-8?q?=E6=88=90=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB=E6=89=8B=E9=A0=86?= =?UTF-8?q?=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FORK.ja.md | 2 +- FORK.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FORK.ja.md b/FORK.ja.md index 57d31c2d..e6af5892 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -50,7 +50,7 @@ cargo install --git https://github.com/shigechika/gws-cli --locked ```bash cd gws-cli -cargo install --path . +cargo install --path crates/google-workspace-cli ``` `~/.cargo/bin/gws` にバイナリがインストールされます。`cargo build --release` は `target/release/gws` にビルドするだけで `~/.cargo/bin/` は**更新されない**点に注意してください。 diff --git a/FORK.md b/FORK.md index a1684350..70290229 100644 --- a/FORK.md +++ b/FORK.md @@ -50,7 +50,7 @@ If you cloned the repository locally, install from the working tree: ```bash cd gws-cli -cargo install --path . +cargo install --path crates/google-workspace-cli ``` This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release` only builds to `target/release/gws` and does **not** update `~/.cargo/bin/`. From 1e63246d47808218be73ce6d7c13757321fb27dd Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Thu, 26 Mar 2026 08:18:21 +0900 Subject: [PATCH 03/44] =?UTF-8?q?docs:=20CLAUDE.md=E3=81=ABworkspace?= =?UTF-8?q?=E6=A7=8B=E6=88=90=E3=81=A8=E3=83=AA=E3=83=99=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E6=89=8B=E6=B3=95=E3=82=92=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 90664405..c1b7bc29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,15 @@ After merging upstream/main, fix MCP compilation errors: 1. `crates/google-workspace-cli/src/mcp_server.rs` — match new arguments in `executor::execute_method()` calls 2. `crates/google-workspace-cli/src/mcp_server.rs` — match new fields in Gmail helper structs 3. `crates/google-workspace-cli/src/helpers/gmail/mod.rs` — ensure `pub(crate)` visibility is not reverted to `pub(super)` -4. Run `cargo clippy -- -D warnings && cargo test` to verify +4. `pub(crate)` targets: `Mailbox`, `to_mb_address_list`, `apply_optional_headers`, `finalize_message`, `resolve_mail_method`, `Attachment` +5. Run `cargo clippy -- -D warnings && cargo test` to verify +6. If conflicts exceed ~20 files, consider rebasing: checkout upstream/main as new branch, re-apply MCP changes, reset main + +### Project Structure + +- Cargo workspace: `crates/google-workspace-cli/` (binary) + `crates/google-workspace/` (library) +- MCP server: `crates/google-workspace-cli/src/mcp_server.rs` +- Local install: `cargo install --path crates/google-workspace-cli` ### GitHub Actions From cc08dbc959ccaeadcdae4af30b38f3a63967dcd3 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 6 Apr 2026 21:21:21 +0900 Subject: [PATCH 04/44] =?UTF-8?q?Merge=20upstream/main=20(v0.22.5):=20serd?= =?UTF-8?q?e=5Fyaml=E2=86=92toml=20migration,=20cargo-deny,=20sheets=20--r?= =?UTF-8?q?ange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream changes (11 commits, v0.22.3 → v0.22.5): - refactor: drop serde_yaml, migrate registry to toml - ci: add cargo-audit and cargo-deny workflows - feat(sheets): add --range flag to +append - fix: npm installer improvements (native fetch, SHA256 verify) - ci: pin cross-rs to v0.2.5 Fork actions: - Resolved release.yml modify/delete conflict (deleted in fork) - sync-upstream.yml: changed to always-PR mode (fixes workflows permission issue) - MCP server and helper tools unaffected --- .changeset/sheets-append-range.md | 5 + .github/workflows/audit.yml | 45 ++ .github/workflows/ci.yml | 13 + .github/workflows/sync-upstream.yml | 61 +- AGENTS.md | 2 +- CHANGELOG.md | 29 + Cargo.lock | 157 +++-- README.md | 13 +- TODO-upstream-rebase.md | 116 ++++ crates/google-workspace-cli/Cargo.toml | 6 +- crates/google-workspace-cli/README.md | 8 +- .../registry/personas.toml | 190 ++++++ .../registry/personas.yaml | 167 ------ .../registry/recipes.toml | 496 ++++++++++++++++ .../registry/recipes.yaml | 560 ------------------ .../src/generate_skills.rs | 19 +- .../src/helpers/sheets.rs | 64 +- crates/google-workspace/Cargo.toml | 2 +- deny.toml | 49 ++ dist-workspace.toml | 44 -- flake.lock | 6 +- npm/.gitignore | 2 + npm/install.js | 170 ++++++ npm/package.json | 79 +++ npm/platform.js | 86 +++ npm/run.js | 36 ++ package.json | 2 +- scripts/version-sync.sh | 9 +- skills/gws-admin-reports/SKILL.md | 2 +- skills/gws-calendar-agenda/SKILL.md | 2 +- skills/gws-calendar-insert/SKILL.md | 2 +- skills/gws-calendar/SKILL.md | 2 +- skills/gws-chat-send/SKILL.md | 2 +- skills/gws-chat/SKILL.md | 2 +- skills/gws-classroom/SKILL.md | 2 +- skills/gws-docs-write/SKILL.md | 2 +- skills/gws-docs/SKILL.md | 2 +- skills/gws-drive-upload/SKILL.md | 2 +- skills/gws-drive/SKILL.md | 2 +- skills/gws-events-renew/SKILL.md | 2 +- skills/gws-events-subscribe/SKILL.md | 2 +- skills/gws-events/SKILL.md | 2 +- skills/gws-forms/SKILL.md | 2 +- skills/gws-gmail-forward/SKILL.md | 2 +- skills/gws-gmail-read/SKILL.md | 2 +- skills/gws-gmail-reply-all/SKILL.md | 2 +- skills/gws-gmail-reply/SKILL.md | 2 +- skills/gws-gmail-send/SKILL.md | 2 +- skills/gws-gmail-triage/SKILL.md | 2 +- skills/gws-gmail-watch/SKILL.md | 2 +- skills/gws-gmail/SKILL.md | 2 +- skills/gws-keep/SKILL.md | 2 +- skills/gws-meet/SKILL.md | 2 +- .../gws-modelarmor-create-template/SKILL.md | 2 +- .../gws-modelarmor-sanitize-prompt/SKILL.md | 2 +- .../gws-modelarmor-sanitize-response/SKILL.md | 2 +- skills/gws-modelarmor/SKILL.md | 2 +- skills/gws-people/SKILL.md | 2 +- skills/gws-script-push/SKILL.md | 2 +- skills/gws-script/SKILL.md | 2 +- skills/gws-shared/SKILL.md | 2 +- skills/gws-sheets-append/SKILL.md | 5 +- skills/gws-sheets-read/SKILL.md | 2 +- skills/gws-sheets/SKILL.md | 2 +- skills/gws-slides/SKILL.md | 2 +- skills/gws-tasks/SKILL.md | 2 +- skills/gws-workflow-email-to-task/SKILL.md | 2 +- skills/gws-workflow-file-announce/SKILL.md | 2 +- skills/gws-workflow-meeting-prep/SKILL.md | 2 +- skills/gws-workflow-standup-report/SKILL.md | 2 +- skills/gws-workflow-weekly-digest/SKILL.md | 2 +- skills/gws-workflow/SKILL.md | 2 +- skills/persona-content-creator/SKILL.md | 2 +- skills/persona-customer-support/SKILL.md | 2 +- skills/persona-event-coordinator/SKILL.md | 2 +- skills/persona-exec-assistant/SKILL.md | 2 +- skills/persona-hr-coordinator/SKILL.md | 2 +- skills/persona-it-admin/SKILL.md | 2 +- skills/persona-project-manager/SKILL.md | 2 +- skills/persona-researcher/SKILL.md | 2 +- skills/persona-sales-ops/SKILL.md | 2 +- skills/persona-team-lead/SKILL.md | 2 +- skills/recipe-backup-sheet-as-csv/SKILL.md | 2 +- skills/recipe-batch-invite-to-event/SKILL.md | 2 +- skills/recipe-block-focus-time/SKILL.md | 2 +- skills/recipe-bulk-download-folder/SKILL.md | 2 +- skills/recipe-collect-form-responses/SKILL.md | 2 +- skills/recipe-compare-sheet-tabs/SKILL.md | 2 +- .../recipe-copy-sheet-for-new-month/SKILL.md | 2 +- .../recipe-create-classroom-course/SKILL.md | 2 +- .../recipe-create-doc-from-template/SKILL.md | 2 +- .../recipe-create-events-from-sheet/SKILL.md | 2 +- skills/recipe-create-expense-tracker/SKILL.md | 2 +- skills/recipe-create-feedback-form/SKILL.md | 2 +- skills/recipe-create-gmail-filter/SKILL.md | 2 +- skills/recipe-create-meet-space/SKILL.md | 2 +- skills/recipe-create-presentation/SKILL.md | 2 +- skills/recipe-create-shared-drive/SKILL.md | 2 +- skills/recipe-create-task-list/SKILL.md | 2 +- .../recipe-create-vacation-responder/SKILL.md | 2 +- skills/recipe-draft-email-from-doc/SKILL.md | 2 +- skills/recipe-email-drive-link/SKILL.md | 2 +- skills/recipe-find-free-time/SKILL.md | 2 +- skills/recipe-find-large-files/SKILL.md | 2 +- skills/recipe-forward-labeled-emails/SKILL.md | 2 +- .../SKILL.md | 2 +- .../recipe-label-and-archive-emails/SKILL.md | 2 +- skills/recipe-log-deal-update/SKILL.md | 2 +- skills/recipe-organize-drive-folder/SKILL.md | 2 +- skills/recipe-plan-weekly-schedule/SKILL.md | 2 +- skills/recipe-post-mortem-setup/SKILL.md | 2 +- skills/recipe-reschedule-meeting/SKILL.md | 2 +- .../recipe-review-meet-participants/SKILL.md | 2 +- skills/recipe-review-overdue-tasks/SKILL.md | 2 +- skills/recipe-save-email-attachments/SKILL.md | 2 +- skills/recipe-save-email-to-doc/SKILL.md | 2 +- .../recipe-schedule-recurring-event/SKILL.md | 2 +- skills/recipe-send-team-announcement/SKILL.md | 2 +- skills/recipe-share-doc-and-notify/SKILL.md | 2 +- skills/recipe-share-event-materials/SKILL.md | 2 +- skills/recipe-share-folder-with-team/SKILL.md | 2 +- skills/recipe-sync-contacts-to-sheet/SKILL.md | 2 +- skills/recipe-watch-drive-changes/SKILL.md | 2 +- 123 files changed, 1642 insertions(+), 987 deletions(-) create mode 100644 .changeset/sheets-append-range.md create mode 100644 .github/workflows/audit.yml create mode 100644 TODO-upstream-rebase.md create mode 100644 crates/google-workspace-cli/registry/personas.toml delete mode 100644 crates/google-workspace-cli/registry/personas.yaml create mode 100644 crates/google-workspace-cli/registry/recipes.toml delete mode 100644 crates/google-workspace-cli/registry/recipes.yaml create mode 100644 deny.toml delete mode 100644 dist-workspace.toml create mode 100644 npm/.gitignore create mode 100644 npm/install.js create mode 100644 npm/package.json create mode 100644 npm/platform.js create mode 100644 npm/run.js diff --git a/.changeset/sheets-append-range.md b/.changeset/sheets-append-range.md new file mode 100644 index 00000000..1f6ec7b2 --- /dev/null +++ b/.changeset/sheets-append-range.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--range` flag to `sheets +append` for targeting specific sheet tabs diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..571cabb8 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Audit + +on: + push: + branches: [main] + paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml'] + pull_request: + branches: [main] + paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml'] + schedule: + - cron: '0 6 * * *' # Daily at 06:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install cargo-audit + uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov + with: + tool: cargo-audit + + - name: Run cargo audit + run: cargo audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9e2db7..3127189d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,19 @@ jobs: - name: Clippy run: cargo clippy --workspace -- -D warnings + deny: + name: Cargo Deny + needs: changes + if: needs.changes.outputs.rust == 'true' || github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Cargo deny + uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15 + with: + command: check + skills: name: Verify Skills diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 78ad0428..bd03919e 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -34,46 +34,65 @@ jobs: echo "count=$COUNT" >> "$GITHUB_OUTPUT" echo "Upstream has $COUNT new commit(s)" - - name: Merge upstream/main + - name: Attempt merge if: steps.check.outputs.count != '0' id: merge run: | + BRANCH="sync-upstream/$(date +%Y%m%d)" + git checkout -b "$BRANCH" + if git merge upstream/main --no-edit; then + # Strip issue/PR number references to prevent cross-references + MSG=$(git log -1 --format=%B) + CLEANED=$(echo "$MSG" | sed 's/ (#[0-9]\+)//g; s/#[0-9]\+//g') + if [ "$MSG" != "$CLEANED" ]; then + git commit --amend -m "$CLEANED" + fi echo "conflict=false" >> "$GITHUB_OUTPUT" else - git merge --abort + git add -A + git commit -m "sync: merge upstream/main (conflicts need manual resolution)" --no-verify || true echo "conflict=true" >> "$GITHUB_OUTPUT" fi - - name: Strip issue/PR number references from merge commit + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + git push -u origin "$BRANCH" + + - name: Create PR (clean merge) if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'false' run: | - MSG=$(git log -1 --format=%B) - CLEANED=$(echo "$MSG" | sed 's/ (#[0-9]\+)//g; s/#[0-9]\+//g') - if [ "$MSG" != "$CLEANED" ]; then - git commit --amend -m "$CLEANED" - fi + UPSTREAM_VER=$(git describe --tags upstream/main 2>/dev/null || git rev-parse --short upstream/main) + gh pr create \ + --title "sync: merge upstream/main ($UPSTREAM_VER)" \ + --body "$(cat <<'EOF' + ## Summary + - upstream/main を自動マージしました(コンフリクトなし) + - レビュー後にマージしてください - - name: Push (no conflict) - if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'false' - run: git push origin main + ### チェックリスト + - [ ] CI が通ること + - [ ] MCP 機能に影響がないこと(`mcp_server.rs`, `helpers/gmail/mod.rs` の `pub(crate)` 可視性) + EOF + )" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create PR (conflict) if: steps.check.outputs.count != '0' && steps.merge.outputs.conflict == 'true' run: | - BRANCH="sync-upstream/$(date +%Y%m%d)" - git checkout -b "$BRANCH" - git merge upstream/main --no-edit || true - git add -A - git commit -m "sync: merge upstream/main (conflicts need manual resolution)" - git push -u origin "$BRANCH" + UPSTREAM_VER=$(git describe --tags upstream/main 2>/dev/null || git rev-parse --short upstream/main) gh pr create \ - --title "sync: merge upstream/main (要手動解決)" \ + --title "sync: merge upstream/main ($UPSTREAM_VER) — 要手動解決" \ --body "$(cat <<'EOF' - upstream の最新変更をマージしようとしましたが、コンフリクトが発生しました。 - 手動で解決してください。 + ## Summary + - upstream/main のマージでコンフリクトが発生しました + - 手動で解決してください - MCP 機能の温存に注意してください(`src/mcp_server.rs` と `src/main.rs` の mcp エントリ)。 + ### MCP 温存チェックリスト + - [ ] `crates/google-workspace-cli/src/mcp_server.rs` が残っていること + - [ ] `crates/google-workspace-cli/src/main.rs` の `mod mcp_server` と MCP エントリが残っていること + - [ ] `crates/google-workspace-cli/src/helpers/gmail/mod.rs` の `pub(crate)` 可視性が維持されていること + - [ ] `cargo clippy -- -D warnings && cargo test` が通ること EOF )" env: diff --git a/AGENTS.md b/AGENTS.md index 07198523..72211226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,7 +178,7 @@ Use these labels to categorize pull requests and issues: - `area: http` — Request execution, URL building, response handling - `area: docs` — README, contributing guides, documentation - `area: tui` — Setup wizard, picker, input fields -- `area: distribution` — Nix flake, cargo-dist, npm packaging, install methods +- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods - `area: auth` — OAuth, credentials, multi-account, ADC - `area: skills` — AI skill generation and management diff --git a/CHANGELOG.md b/CHANGELOG.md index 87249a5f..f332f4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # @googleworkspace/cli +## 0.22.5 + +### Patch Changes + +- 5d24ac2: Add cargo-audit CI workflow for automated dependency vulnerability scanning +- ecddf2e: Add cargo-deny configuration for license, advisory, and source auditing +- 503315b: Update installation instructions to prioritize GitHub Releases over npm +- 6ccbb42: fix: auto-install binary on run if missing + + pnpm skips postinstall when the package is already up to date. + This ensures run.js will auto-trigger install.js if the + binary is missing, fixing the 'gws binary not found' error. + +- b307856: Migrated the internal AI skills registry (personas and recipes) from YAML to TOML. This allows us to drop the unmaintained serde_yaml dependency, improving the project's supply chain security posture. +- 158f93a: Verify SHA256 checksum of downloaded binary in npm postinstall script +- b422e5d: Pin cross-rs to v0.2.5 in release workflow to prevent unpinned git HEAD builds + +## 0.22.4 + +### Patch Changes + +- 86c08cf: Remove cargo-dist; use native Node.js fetch for npm binary installer + + Replaces the cargo-dist generated release pipeline and npm package with: + + - A custom GitHub Actions release workflow with matrix cross-compilation + - A zero-dependency npm installer using native `fetch()` (Node 18+) + - Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package + ## 0.22.3 ### Patch Changes diff --git a/Cargo.lock b/Cargo.lock index 5bc737b8..b7e7baec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "google-workspace" -version = "0.22.3" +version = "0.22.5" dependencies = [ "anyhow", "percent-encoding", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "google-workspace-cli" -version = "0.22.3" +version = "0.22.5" dependencies = [ "aes-gcm", "anyhow", @@ -939,13 +939,13 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yaml", "serial_test", "sha2", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -1063,9 +1063,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1078,7 +1078,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1313,9 +1312,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1344,10 +1343,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1412,9 +1413,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -1542,9 +1543,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1808,12 +1809,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "polyval" version = "0.6.2" @@ -2201,9 +2196,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -2408,28 +2403,24 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", "serde", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "indexmap", + "form_urlencoded", "itoa", "ryu", "serde", - "unsafe-libyaml", ] [[package]] @@ -2870,6 +2861,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -3070,12 +3102,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -3108,9 +3134,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -3175,9 +3201,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -3188,23 +3214,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3212,9 +3234,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -3225,9 +3247,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -3281,9 +3303,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" dependencies = [ "js-sys", "wasm-bindgen", @@ -3674,6 +3696,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3818,18 +3849,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 14b0fa5b..04c532d0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,7 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS


-```bash -npm install -g @googleworkspace/cli -``` +⬇️ **[Download the latest release for your OS](https://github.com/googleworkspace/cli/releases)** `gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically. @@ -46,15 +44,14 @@ npm install -g @googleworkspace/cli ## Installation +The recommended way to install `gws` is to download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. Extract the archive and place the `gws` binary in your `$PATH`. + +For convenience, you can also use `npm` to automate downloading the appropriate binary from GitHub Releases: + ```bash npm install -g @googleworkspace/cli ``` -> The npm package bundles pre-built native binaries for your OS and architecture. -> No Rust toolchain required. - -Pre-built binaries are also available on the [GitHub Releases](https://github.com/googleworkspace/cli/releases) page. - Or build from source: ```bash diff --git a/TODO-upstream-rebase.md b/TODO-upstream-rebase.md new file mode 100644 index 00000000..0dc22c64 --- /dev/null +++ b/TODO-upstream-rebase.md @@ -0,0 +1,116 @@ +# upstream v0.22.1 ベースに MCP を載せ直す + +## 背景 + +- upstream が v0.19.0 で **cargo ワークスペース化**(`src/` → `crates/google-workspace-cli/src/`) +- upstream が `mail-builder` クレートに移行し、自前の `MessageBuilder` を廃止 +- 265 コミット・108 ファイルのコンフリクトにより通常マージは困難 +- **方針: upstream を丸ごとベースにして MCP を載せ直す** + +## 作業手順 + +### 1. upstream/main をベースにブランチ作成 + +```bash +git fetch upstream +git checkout -b rebase-mcp-on-upstream upstream/main +``` + +### 2. フォーク独自ファイルを追加 + +- [ ] `crates/google-workspace-cli/src/mcp_server.rs` — MCP サーバー本体(1,368行) + - **注意**: `execute_method()` のシグネチャが変更済み + - `upload_path: Option<&str>` → `upload: Option>` に変更 + - MCP から呼ぶ場合は `None` でOK(ファイルアップロードしないため) + - **注意**: `MessageBuilder` が `mail_builder::MessageBuilder` に変更済み + - `handle_gmail_send()` を新 API に書き直す必要あり + - 参考: `crates/google-workspace-cli/src/helpers/gmail/send.rs` の `create_send_raw_message()` + - **注意**: `build_raw_send_body()` が廃止 → `dispatch_raw_email()` に統合 + - MCP 用に `dispatch_raw_email` 相当のロジックを組み直す or 関数を `pub(crate)` にして再利用 +- [ ] `FORK.md` — 英語版フォーク説明(現行ファイルをそのままコピー) +- [ ] `FORK.ja.md` — 日本語版フォーク説明(現行ファイルをそのままコピー) +- [ ] `CLAUDE.md` — フォーク固有の注記を追記(upstream 版に追記する形) +- [ ] `.github/workflows/sync-upstream.yml` — upstream 同期ワークフロー +- [ ] `.changeset/mcp-helper-tools.md` — changeset + +### 3. 既存ファイルの修正 + +- [ ] `crates/google-workspace-cli/src/main.rs` + - `mod mcp_server;` 追加 + - `if first_arg == "mcp"` エントリポイント追加(helpers 統合部分の前に配置) +- [ ] `crates/google-workspace-cli/src/helpers/gmail/mod.rs` + - MCP から使う関数・型を `pub(super)` → `pub(crate)` に変更 + - 対象(upstream の新構造で要確認): + - `resolve_mail_method()` (旧 `resolve_send_method()`) + - `dispatch_raw_email()` (MCP から直接呼ぶなら) + - `finalize_message()` (MCP で RFC 2822 を組み立てるなら) + - `Mailbox`, `to_mb_address_list()` 等 + - `ThreadingHeaders` + +### 4. mcp_server.rs の `handle_gmail_send()` 書き直し + +upstream の `send.rs::create_send_raw_message()` を参考に: + +```rust +// 旧(自前 MessageBuilder) +let raw_message = crate::helpers::gmail::MessageBuilder { + to, subject, from: None, cc, bcc, threading: None, html: false, +}.build(body_text); +let send_body = crate::helpers::gmail::build_raw_send_body(&raw_message, None); + +// 新(mail_builder) +let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(&to_mailboxes)) + .subject(subject); +let mb = apply_optional_headers(mb, None, cc_mailboxes, bcc_mailboxes); +let raw = finalize_message(mb, body_text, false, &[])?; +// → dispatch_raw_email() 相当のロジックで execute_method() 呼び出し +``` + +### 5. 不要ワークフロー削除 + +upstream にある以下を削除: +- `.github/workflows/automation.yml` +- `.github/workflows/cla.yml` +- `.github/workflows/coverage.yml` +- `.github/workflows/generate-skills.yml` +- `.github/workflows/publish-skills.yml` +- `.github/workflows/release-changesets.yml` +- `.github/workflows/release.yml` +- `.github/workflows/stale.yml` + +### 6. 検証 + +```bash +cargo clippy -- -D warnings +cargo test +``` + +### 7. main にマージ + +```bash +git checkout main +git reset --hard rebase-mcp-on-upstream +# または merge +git push origin main --force-with-lease +``` + +## 参照ファイル(現行フォーク) + +作業中に参照すべき現行ファイル(`main` ブランチ): +- `src/mcp_server.rs` — MCP 実装の元ネタ +- `src/main.rs:141-143` — MCP エントリポイント +- `FORK.md`, `FORK.ja.md` — そのままコピー +- `CLAUDE.md` — フォーク固有部分を抽出して追記 +- `.github/workflows/sync-upstream.yml` — そのままコピー + +## upstream の新 API ポイント + +| 旧(v0.16.0) | 新(v0.22.1) | +|---|---| +| `src/` | `crates/google-workspace-cli/src/` | +| 自前 `MessageBuilder` struct | `mail_builder::MessageBuilder` | +| `build_raw_send_body()` | `dispatch_raw_email()` に統合 | +| `resolve_send_method()` | `resolve_mail_method(doc, draft)` | +| `upload_path: Option<&str>` + `upload_content_type: Option<&str>` | `upload: Option>` | +| `Cargo.toml`(単一 crate) | `Cargo.toml`(workspace) + `crates/*/Cargo.toml` | diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index 01fd74e4..058b109e 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "google-workspace-cli" -version = "0.22.3" +version = "0.22.5" edition = "2021" description = "Google Workspace CLI — dynamic command surface from Discovery Service" license = "Apache-2.0" @@ -30,7 +30,7 @@ name = "gws" path = "src/main.rs" [dependencies] -google-workspace = { version = "0.22.3", path = "../google-workspace" } +google-workspace = { version = "0.22.5", path = "../google-workspace" } tempfile = "3" aes-gcm = "0.10" anyhow = "1" @@ -58,7 +58,7 @@ chrono-tz = "0.10" iana-time-zone = "0.1" mail-builder = "0.4" async-trait = "0.1.89" -serde_yaml = "0.9.34" +toml = "0.8" percent-encoding = "2.3.2" zeroize = { version = "1.8.2", features = ["derive"] } tracing = "0.1" diff --git a/crates/google-workspace-cli/README.md b/crates/google-workspace-cli/README.md index d7eee778..901fae46 100644 --- a/crates/google-workspace-cli/README.md +++ b/crates/google-workspace-cli/README.md @@ -6,14 +6,16 @@ ## Install +Download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. + +Alternatively, you can use package managers as a convenience layer: + ```bash -npm install -g @googleworkspace/cli # npm +npm install -g @googleworkspace/cli # npm (downloads GitHub release binary) cargo install google-workspace-cli # crates.io nix run github:googleworkspace/cli # nix ``` -Pre-built binaries are available on the [GitHub Releases](https://github.com/googleworkspace/cli/releases) page. - ## Quick Start ```bash diff --git a/crates/google-workspace-cli/registry/personas.toml b/crates/google-workspace-cli/registry/personas.toml new file mode 100644 index 00000000..5912bead --- /dev/null +++ b/crates/google-workspace-cli/registry/personas.toml @@ -0,0 +1,190 @@ +[[personas]] +name = "exec-assistant" +title = "Executive Assistant" +description = "Manage an executive's schedule, inbox, and communications." +services = [ "gmail", "calendar", "drive", "chat" ] +workflows = [ "+standup-report", "+meeting-prep", "+weekly-digest" ] +instructions = [ + "Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks.", + "Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs.", + "Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership.", + "Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`.", + "Draft replies with `gws gmail +send` — keep tone professional and concise." +] +tips = [ + "Always confirm calendar changes with the executive before committing.", + "Use `--format table` for quick visual scans of agenda and triage output.", + "Check `gws calendar +agenda --week` on Monday mornings for weekly planning." +] + +[[personas]] +name = "project-manager" +title = "Project Manager" +description = "Coordinate projects — track tasks, schedule meetings, and share docs." +services = [ "drive", "sheets", "calendar", "gmail", "chat" ] +workflows = [ "+standup-report", "+weekly-digest", "+file-announce" ] +instructions = [ + "Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items.", + "Track project status in Sheets using `gws sheets +append` to log updates.", + "Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`.", + "Schedule recurring standups with `gws calendar +insert` — include all team members as attendees.", + "Send status update emails to stakeholders with `gws gmail +send`." +] +tips = [ + "Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders.", + "Pipe triage output through `jq` for filtering by sender or subject.", + "Use `--dry-run` before any write operations to preview what will happen." +] + +[[personas]] +name = "hr-coordinator" +title = "HR Coordinator" +description = "Handle HR workflows — onboarding, announcements, and employee comms." +services = [ "gmail", "calendar", "drive", "chat" ] +workflows = [ "+email-to-task", "+file-announce" ] +instructions = [ + "For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`.", + "Upload onboarding docs to a shared Drive folder with `gws drive +upload`.", + "Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc.", + "Convert email requests into tracked tasks with `gws workflow +email-to-task`.", + "Send bulk announcements with `gws gmail +send` — use clear subject lines." +] +tips = [ + "Always use `--sanitize` for PII-sensitive operations.", + "Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules." +] + +[[personas]] +name = "sales-ops" +title = "Sales Operations" +description = "Manage sales workflows — track deals, schedule calls, client comms." +services = [ "gmail", "calendar", "sheets", "drive" ] +workflows = [ "+meeting-prep", "+email-to-task", "+weekly-digest" ] +instructions = [ + "Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda.", + "Log deal updates in a tracking spreadsheet with `gws sheets +append`.", + "Convert follow-up emails into tasks with `gws workflow +email-to-task`.", + "Share proposals by uploading to Drive with `gws drive +upload`.", + "Get a weekly sales pipeline summary with `gws workflow +weekly-digest`." +] +tips = [ + "Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails.", + "Schedule follow-up calls immediately after meetings to maintain momentum.", + "Keep all client-facing documents in a dedicated shared Drive folder." +] + +[[personas]] +name = "it-admin" +title = "IT Administrator" +description = "Administer IT — monitor security and configure Workspace." +services = [ "gmail", "drive", "calendar" ] +workflows = [ "+standup-report" ] +instructions = [ + "Start the day with `gws workflow +standup-report` to review any pending IT requests.", + "Monitor suspicious login activity and review audit logs.", + "Configure Drive sharing policies to enforce organizational security." +] +tips = [ + "Always use `--dry-run` before bulk operations.", + "Review `gws auth status` regularly to verify service account permissions." +] + +[[personas]] +name = "content-creator" +title = "Content Creator" +description = "Create, organize, and distribute content across Workspace." +services = [ "docs", "drive", "gmail", "chat", "slides" ] +workflows = [ "+file-announce" ] +instructions = [ + "Draft content in Google Docs with `gws docs +write`.", + "Organize content assets in Drive folders — use `gws drive files list` to browse.", + "Share finished content by announcing in Chat with `gws workflow +file-announce`.", + "Send content review requests via email with `gws gmail +send`.", + "Upload media assets to Drive with `gws drive +upload`." +] +tips = [ + "Use `gws docs +write` for quick content updates — it handles the Docs API formatting.", + "Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules.", + "Use `--format yaml` for human-readable output when debugging API responses." +] + +[[personas]] +name = "customer-support" +title = "Customer Support Agent" +description = "Manage customer support — track tickets, respond, escalate issues." +services = [ "gmail", "sheets", "chat", "calendar" ] +workflows = [ "+email-to-task", "+standup-report" ] +instructions = [ + "Triage the support inbox with `gws gmail +triage --query 'label:support'`.", + "Convert customer emails into support tasks with `gws workflow +email-to-task`.", + "Log ticket status updates in a tracking sheet with `gws sheets +append`.", + "Escalate urgent issues to the team Chat space.", + "Schedule follow-up calls with customers using `gws calendar +insert`." +] +tips = [ + "Use `gws gmail +triage --labels` to see email categories at a glance.", + "Set up Gmail filters for auto-labeling support requests.", + "Use `--format table` for quick status dashboard views." +] + +[[personas]] +name = "event-coordinator" +title = "Event Coordinator" +description = "Plan and manage events — scheduling, invitations, and logistics." +services = [ "calendar", "gmail", "drive", "chat", "sheets" ] +workflows = [ "+meeting-prep", "+file-announce", "+weekly-digest" ] +instructions = [ + "Create event calendar entries with `gws calendar +insert` — include location and attendee lists.", + "Prepare event materials and upload to Drive with `gws drive +upload`.", + "Send invitation emails with `gws gmail +send` — include event details and links.", + "Announce updates in Chat spaces with `gws workflow +file-announce`.", + "Track RSVPs and logistics in Sheets with `gws sheets +append`." +] +tips = [ + "Use `gws calendar +agenda --days 30` for long-range event planning.", + "Create a dedicated calendar for each major event series.", + "Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites." +] + +[[personas]] +name = "team-lead" +title = "Team Lead" +description = "Lead a team — run standups, coordinate tasks, and communicate." +services = [ "calendar", "gmail", "chat", "drive", "sheets" ] +workflows = [ + "+standup-report", + "+meeting-prep", + "+weekly-digest", + "+email-to-task" +] +instructions = [ + "Run daily standups with `gws workflow +standup-report` — share output in team Chat.", + "Prepare for 1:1s with `gws workflow +meeting-prep`.", + "Get weekly snapshots with `gws workflow +weekly-digest`.", + "Delegate email action items with `gws workflow +email-to-task`.", + "Track team OKRs in a shared Sheet with `gws sheets +append`." +] +tips = [ + "Use `gws calendar +agenda --week --format table` for weekly team calendar views.", + "Pipe standup reports to Chat with `gws chat spaces messages create`.", + "Use `--sanitize` for any operations involving sensitive team data." +] + +[[personas]] +name = "researcher" +title = "Researcher" +description = "Organize research — manage references, notes, and collaboration." +services = [ "drive", "docs", "sheets", "gmail" ] +workflows = [ "+file-announce" ] +instructions = [ + "Organize research papers and notes in Drive folders.", + "Write research notes and summaries with `gws docs +write`.", + "Track research data in Sheets — use `gws sheets +append` for data logging.", + "Share findings with collaborators via `gws workflow +file-announce`.", + "Request peer reviews via `gws gmail +send`." +] +tips = [ + "Use `gws drive files list` with search queries to find specific documents.", + "Keep a running log of experiments and findings in a shared Sheet.", + "Use `--format csv` when exporting data for analysis tools." +] diff --git a/crates/google-workspace-cli/registry/personas.yaml b/crates/google-workspace-cli/registry/personas.yaml deleted file mode 100644 index 2aac86af..00000000 --- a/crates/google-workspace-cli/registry/personas.yaml +++ /dev/null @@ -1,167 +0,0 @@ -# Persona Packs — Role-based skill bundles for AI agents -# -# Each persona defines a role-based context with: -# - name: unique id (used as directory name: persona-{name}) -# - title: human-readable name -# - description: when to use this persona -# - services: which gws services this persona commonly uses -# - workflows: which workflow commands are relevant -# - instructions: step-by-step guidance for agents adopting this role -# - tips: useful reminders - -personas: - - name: exec-assistant - title: Executive Assistant - description: "Manage an executive's schedule, inbox, and communications." - services: [gmail, calendar, drive, chat] - workflows: ["+standup-report", "+meeting-prep", "+weekly-digest"] - instructions: - - "Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks." - - "Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs." - - "Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership." - - "Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`." - - "Draft replies with `gws gmail +send` — keep tone professional and concise." - tips: - - "Always confirm calendar changes with the executive before committing." - - "Use `--format table` for quick visual scans of agenda and triage output." - - "Check `gws calendar +agenda --week` on Monday mornings for weekly planning." - - - name: project-manager - title: Project Manager - description: "Coordinate projects — track tasks, schedule meetings, and share docs." - services: [drive, sheets, calendar, gmail, chat] - workflows: ["+standup-report", "+weekly-digest", "+file-announce"] - instructions: - - "Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items." - - "Track project status in Sheets using `gws sheets +append` to log updates." - - "Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`." - - "Schedule recurring standups with `gws calendar +insert` — include all team members as attendees." - - "Send status update emails to stakeholders with `gws gmail +send`." - tips: - - "Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders." - - "Pipe triage output through `jq` for filtering by sender or subject." - - "Use `--dry-run` before any write operations to preview what will happen." - - - name: hr-coordinator - title: HR Coordinator - description: "Handle HR workflows — onboarding, announcements, and employee comms." - services: [gmail, calendar, drive, chat] - workflows: ["+email-to-task", "+file-announce"] - instructions: - - "For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`." - - "Upload onboarding docs to a shared Drive folder with `gws drive +upload`." - - "Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc." - - "Convert email requests into tracked tasks with `gws workflow +email-to-task`." - - "Send bulk announcements with `gws gmail +send` — use clear subject lines." - tips: - - "Always use `--sanitize` for PII-sensitive operations." - - "Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules." - - - name: sales-ops - title: Sales Operations - description: "Manage sales workflows — track deals, schedule calls, client comms." - services: [gmail, calendar, sheets, drive] - workflows: ["+meeting-prep", "+email-to-task", "+weekly-digest"] - instructions: - - "Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda." - - "Log deal updates in a tracking spreadsheet with `gws sheets +append`." - - "Convert follow-up emails into tasks with `gws workflow +email-to-task`." - - "Share proposals by uploading to Drive with `gws drive +upload`." - - "Get a weekly sales pipeline summary with `gws workflow +weekly-digest`." - tips: - - "Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails." - - "Schedule follow-up calls immediately after meetings to maintain momentum." - - "Keep all client-facing documents in a dedicated shared Drive folder." - - - name: it-admin - title: IT Administrator - description: "Administer IT — monitor security and configure Workspace." - services: [gmail, drive, calendar] - workflows: ["+standup-report"] - instructions: - - "Start the day with `gws workflow +standup-report` to review any pending IT requests." - - "Monitor suspicious login activity and review audit logs." - - "Configure Drive sharing policies to enforce organizational security." - tips: - - "Always use `--dry-run` before bulk operations." - - "Review `gws auth status` regularly to verify service account permissions." - - - name: content-creator - title: Content Creator - description: "Create, organize, and distribute content across Workspace." - services: [docs, drive, gmail, chat, slides] - workflows: ["+file-announce"] - instructions: - - "Draft content in Google Docs with `gws docs +write`." - - "Organize content assets in Drive folders — use `gws drive files list` to browse." - - "Share finished content by announcing in Chat with `gws workflow +file-announce`." - - "Send content review requests via email with `gws gmail +send`." - - "Upload media assets to Drive with `gws drive +upload`." - tips: - - "Use `gws docs +write` for quick content updates — it handles the Docs API formatting." - - "Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules." - - "Use `--format yaml` for human-readable output when debugging API responses." - - - name: customer-support - title: Customer Support Agent - description: "Manage customer support — track tickets, respond, escalate issues." - services: [gmail, sheets, chat, calendar] - workflows: ["+email-to-task", "+standup-report"] - instructions: - - "Triage the support inbox with `gws gmail +triage --query 'label:support'`." - - "Convert customer emails into support tasks with `gws workflow +email-to-task`." - - "Log ticket status updates in a tracking sheet with `gws sheets +append`." - - "Escalate urgent issues to the team Chat space." - - "Schedule follow-up calls with customers using `gws calendar +insert`." - tips: - - "Use `gws gmail +triage --labels` to see email categories at a glance." - - "Set up Gmail filters for auto-labeling support requests." - - "Use `--format table` for quick status dashboard views." - - - name: event-coordinator - title: Event Coordinator - description: "Plan and manage events — scheduling, invitations, and logistics." - services: [calendar, gmail, drive, chat, sheets] - workflows: ["+meeting-prep", "+file-announce", "+weekly-digest"] - instructions: - - "Create event calendar entries with `gws calendar +insert` — include location and attendee lists." - - "Prepare event materials and upload to Drive with `gws drive +upload`." - - "Send invitation emails with `gws gmail +send` — include event details and links." - - "Announce updates in Chat spaces with `gws workflow +file-announce`." - - "Track RSVPs and logistics in Sheets with `gws sheets +append`." - tips: - - "Use `gws calendar +agenda --days 30` for long-range event planning." - - "Create a dedicated calendar for each major event series." - - "Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites." - - - name: team-lead - title: Team Lead - description: "Lead a team — run standups, coordinate tasks, and communicate." - services: [calendar, gmail, chat, drive, sheets] - workflows: ["+standup-report", "+meeting-prep", "+weekly-digest", "+email-to-task"] - instructions: - - "Run daily standups with `gws workflow +standup-report` — share output in team Chat." - - "Prepare for 1:1s with `gws workflow +meeting-prep`." - - "Get weekly snapshots with `gws workflow +weekly-digest`." - - "Delegate email action items with `gws workflow +email-to-task`." - - "Track team OKRs in a shared Sheet with `gws sheets +append`." - tips: - - "Use `gws calendar +agenda --week --format table` for weekly team calendar views." - - "Pipe standup reports to Chat with `gws chat spaces messages create`." - - "Use `--sanitize` for any operations involving sensitive team data." - - - name: researcher - title: Researcher - description: "Organize research — manage references, notes, and collaboration." - services: [drive, docs, sheets, gmail] - workflows: ["+file-announce"] - instructions: - - "Organize research papers and notes in Drive folders." - - "Write research notes and summaries with `gws docs +write`." - - "Track research data in Sheets — use `gws sheets +append` for data logging." - - "Share findings with collaborators via `gws workflow +file-announce`." - - "Request peer reviews via `gws gmail +send`." - tips: - - "Use `gws drive files list` with search queries to find specific documents." - - "Keep a running log of experiments and findings in a shared Sheet." - - "Use `--format csv` when exporting data for analysis tools." diff --git a/crates/google-workspace-cli/registry/recipes.toml b/crates/google-workspace-cli/registry/recipes.toml new file mode 100644 index 00000000..8440ca1b --- /dev/null +++ b/crates/google-workspace-cli/registry/recipes.toml @@ -0,0 +1,496 @@ +[[recipes]] +name = "label-and-archive-emails" +title = "Label and Archive Gmail Threads" +description = "Apply Gmail labels to matching messages and archive them to keep your inbox clean." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`", + "Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`", + "Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`" +] + +[[recipes]] +name = "draft-email-from-doc" +title = "Draft a Gmail Message from a Google Doc" +description = "Read content from a Google Doc and use it as the body of a Gmail message." +category = "productivity" +services = [ "docs", "gmail" ] +steps = [ + "Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`", + "Copy the text from the body content", + "Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`" +] + +[[recipes]] +name = "organize-drive-folder" +title = "Organize Files into Google Drive Folders" +description = "Create a Google Drive folder structure and move files into the right locations." +category = "productivity" +services = [ "drive" ] +steps = [ + "Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`", + "Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`", + "Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`", + "Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`" +] + +[[recipes]] +name = "share-folder-with-team" +title = "Share a Google Drive Folder with a Team" +description = "Share a Google Drive folder and all its contents with a list of collaborators." +category = "productivity" +services = [ "drive" ] +steps = [ + "Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`", + "Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`", + "Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`", + "Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`" +] + +[[recipes]] +name = "email-drive-link" +title = "Email a Google Drive File Link" +description = "Share a Google Drive file and email the link with a message to recipients." +category = "productivity" +services = [ "drive", "gmail" ] +steps = [ + "Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`", + "Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`", + "Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`" +] + +[[recipes]] +name = "create-doc-from-template" +title = "Create a Google Doc from a Template" +description = "Copy a Google Docs template, fill in content, and share with collaborators." +category = "productivity" +services = [ "drive", "docs" ] +steps = [ + "Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`", + "Get the new doc ID from the response", + "Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`", + "Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" +] + +[[recipes]] +name = "create-expense-tracker" +title = "Create a Google Sheets Expense Tracker" +description = "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." +category = "productivity" +services = [ "sheets", "drive" ] +steps = [ + "Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`", + "Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`", + "Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`", + "Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`" +] + +[[recipes]] +name = "copy-sheet-for-new-month" +title = "Copy a Google Sheet for a New Month" +description = "Duplicate a Google Sheets template tab for a new month of tracking." +category = "productivity" +services = [ "sheets" ] +steps = [ + "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`", + "Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`", + "Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`" +] + +[[recipes]] +name = "block-focus-time" +title = "Block Focus Time on Google Calendar" +description = "Create recurring focus time blocks on Google Calendar to protect deep work hours." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`", + "Verify it shows as busy: `gws calendar +agenda`" +] + +[[recipes]] +name = "reschedule-meeting" +title = "Reschedule a Google Calendar Meeting" +description = "Move a Google Calendar event to a new time and automatically notify all attendees." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Find the event: `gws calendar +agenda`", + "Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`" +] + +[[recipes]] +name = "create-gmail-filter" +title = "Create a Gmail Filter" +description = "Create a Gmail filter to automatically label, star, or categorize incoming messages." +category = "productivity" +services = [ "gmail" ] +steps = [ + "List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`", + "Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`", + "Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`", + "Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`" +] + +[[recipes]] +name = "schedule-recurring-event" +title = "Schedule a Recurring Meeting" +description = "Create a recurring Google Calendar event with attendees." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`", + "Verify it was created: `gws calendar +agenda --days 14 --format table`" +] + +[[recipes]] +name = "find-free-time" +title = "Find Free Time Across Calendars" +description = "Query Google Calendar free/busy status for multiple users to find a meeting slot." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`", + "Review the output to find overlapping free slots", + "Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`" +] + +[[recipes]] +name = "bulk-download-folder" +title = "Bulk Download Drive Folder" +description = "List and download all files from a Google Drive folder." +category = "productivity" +services = [ "drive" ] +steps = [ + "List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`", + "Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`", + "Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`" +] + +[[recipes]] +name = "find-large-files" +title = "Find Largest Files in Drive" +description = "Identify large Google Drive files consuming storage quota." +category = "productivity" +services = [ "drive" ] +steps = [ + "List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`", + "Review the output and identify files to archive or move" +] + +[[recipes]] +name = "create-shared-drive" +title = "Create and Configure a Shared Drive" +description = "Create a Google Shared Drive and add members with appropriate roles." +category = "productivity" +services = [ "drive" ] +steps = [ + "Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`", + "Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`", + "List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`" +] + +[[recipes]] +name = "log-deal-update" +title = "Log Deal Update to Sheet" +description = "Append a deal status update to a Google Sheets sales tracking spreadsheet." +category = "sales" +services = [ "sheets", "drive" ] +steps = [ + "Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`", + "Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`", + "Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"$50,000\", \"Q2\", \"jdoe\"]'`" +] + +[[recipes]] +name = "collect-form-responses" +title = "Check Form Responses" +description = "Retrieve and review responses from a Google Form." +category = "productivity" +services = [ "forms" ] +steps = [ + "List forms: `gws forms forms list` (if you don't have the form ID)", + "Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`", + "Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`" +] + +[[recipes]] +name = "post-mortem-setup" +title = "Set Up Post-Mortem" +description = "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." +category = "engineering" +services = [ "docs", "calendar", "chat" ] +steps = [ + "Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`", + "Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`", + "Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`" +] + +[[recipes]] +name = "create-task-list" +title = "Create a Task List and Add Tasks" +description = "Set up a new Google Tasks list with initial tasks." +category = "productivity" +services = [ "tasks" ] +steps = [ + "Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`", + "Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`", + "Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`", + "List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`" +] + +[[recipes]] +name = "review-overdue-tasks" +title = "Review Overdue Tasks" +description = "Find Google Tasks that are past due and need attention." +category = "productivity" +services = [ "tasks" ] +steps = [ + "List task lists: `gws tasks tasklists list --format table`", + "List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`", + "Review due dates and prioritize overdue items" +] + +[[recipes]] +name = "watch-drive-changes" +title = "Watch for Drive Changes" +description = "Subscribe to change notifications on a Google Drive file or folder." +category = "engineering" +services = [ "events" ] +steps = [ + "Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`", + "List active subscriptions: `gws events subscriptions list`", + "Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`" +] + +[[recipes]] +name = "create-classroom-course" +title = "Create a Google Classroom Course" +description = "Create a Google Classroom course and invite students." +category = "education" +services = [ "classroom" ] +steps = [ + "Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`", + "Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`", + "List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`" +] + +[[recipes]] +name = "create-meet-space" +title = "Create a Google Meet Conference" +description = "Create a Google Meet meeting space and share the join link." +category = "scheduling" +services = [ "meet", "gmail" ] +steps = [ + "Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`", + "Copy the meeting URI from the response", + "Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`" +] + +[[recipes]] +name = "review-meet-participants" +title = "Review Google Meet Attendance" +description = "Review who attended a Google Meet conference and for how long." +category = "productivity" +services = [ "meet" ] +steps = [ + "List recent conferences: `gws meet conferenceRecords list --format table`", + "List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`", + "Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`" +] + +[[recipes]] +name = "create-presentation" +title = "Create a Google Slides Presentation" +description = "Create a new Google Slides presentation and add initial slides." +category = "productivity" +services = [ "slides" ] +steps = [ + "Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`", + "Get the presentation ID from the response", + "Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" +] + +[[recipes]] +name = "save-email-attachments" +title = "Save Gmail Attachments to Google Drive" +description = "Find Gmail messages with attachments and save them to a Google Drive folder." +category = "productivity" +services = [ "gmail", "drive" ] +steps = [ + "Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`", + "Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`", + "Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`", + "Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`" +] + +[[recipes]] +name = "send-team-announcement" +title = "Announce via Gmail and Google Chat" +description = "Send a team announcement via both Gmail and a Google Chat space." +category = "communication" +services = [ "gmail", "chat" ] +steps = [ + "Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`", + "Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`" +] + +[[recipes]] +name = "create-feedback-form" +title = "Create and Share a Google Form" +description = "Create a Google Form for feedback and share it via Gmail." +category = "productivity" +services = [ "forms", "gmail" ] +steps = [ + "Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`", + "Get the form URL from the response (responderUri field)", + "Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`" +] + +[[recipes]] +name = "sync-contacts-to-sheet" +title = "Export Google Contacts to Sheets" +description = "Export Google Contacts directory to a Google Sheets spreadsheet." +category = "productivity" +services = [ "people", "sheets" ] +steps = [ + "List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`", + "Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`", + "Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`" +] + +[[recipes]] +name = "share-event-materials" +title = "Share Files with Meeting Attendees" +description = "Share Google Drive files with all attendees of a Google Calendar event." +category = "productivity" +services = [ "calendar", "drive" ] +steps = [ + "Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`", + "Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`" +] + +[[recipes]] +name = "create-vacation-responder" +title = "Set Up a Gmail Vacation Responder" +description = "Enable a Gmail out-of-office auto-reply with a custom message and date range." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`", + "Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`", + "Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`" +] + +[[recipes]] +name = "create-events-from-sheet" +title = "Create Google Calendar Events from a Sheet" +description = "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." +category = "productivity" +services = [ "sheets", "calendar" ] +steps = [ + "Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`", + "For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`" +] + +[[recipes]] +name = "plan-weekly-schedule" +title = "Plan Your Weekly Google Calendar Schedule" +description = "Review your Google Calendar week, identify gaps, and add events to fill them." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Check this week's agenda: `gws calendar +agenda`", + "Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`", + "Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`", + "Review updated schedule: `gws calendar +agenda`" +] + +[[recipes]] +name = "share-doc-and-notify" +title = "Share a Google Doc and Notify Collaborators" +description = "Share a Google Docs document with edit access and email collaborators the link." +category = "productivity" +services = [ "drive", "docs", "gmail" ] +steps = [ + "Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`", + "Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`", + "Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`" +] + +[[recipes]] +name = "backup-sheet-as-csv" +title = "Export a Google Sheet as CSV" +description = "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." +category = "productivity" +services = [ "sheets", "drive" ] +steps = [ + "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`", + "Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`", + "Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`" +] + +[[recipes]] +name = "save-email-to-doc" +title = "Save a Gmail Message to Google Docs" +description = "Save a Gmail message body into a Google Doc for archival or reference." +category = "productivity" +services = [ "gmail", "docs" ] +steps = [ + "Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`", + "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`", + "Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`", + "Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`" +] + +[[recipes]] +name = "compare-sheet-tabs" +title = "Compare Two Google Sheets Tabs" +description = "Read data from two tabs in a Google Sheet to compare and identify differences." +category = "productivity" +services = [ "sheets" ] +steps = [ + "Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`", + "Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`", + "Compare the data and identify changes" +] + +[[recipes]] +name = "batch-invite-to-event" +title = "Add Multiple Attendees to a Calendar Event" +description = "Add a list of attendees to an existing Google Calendar event and send notifications." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`", + "Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" +] + +[[recipes]] +name = "forward-labeled-emails" +title = "Forward Labeled Gmail Messages" +description = "Find Gmail messages with a specific label and forward them to another address." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`", + "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`", + "Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`" +] + +[[recipes]] +name = "generate-report-from-sheet" +title = "Generate a Google Docs Report from Sheet Data" +description = "Read data from a Google Sheet and create a formatted Google Docs report." +category = "productivity" +services = [ "sheets", "docs", "drive" ] +steps = [ + "Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`", + "Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`", + "Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: $125,000\n\n### Top Deals\n1. Acme Corp - $25,000\n2. Widget Inc - $18,000'`", + "Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`" +] diff --git a/crates/google-workspace-cli/registry/recipes.yaml b/crates/google-workspace-cli/registry/recipes.yaml deleted file mode 100644 index e407404e..00000000 --- a/crates/google-workspace-cli/registry/recipes.yaml +++ /dev/null @@ -1,560 +0,0 @@ -# Curated Recipe Registry — Real-world Google Workspace workflows -# -# Each recipe defines a reusable multi-step task with: -# - name: unique id (directory: recipe-{name}) -# - title: human-readable name -# - description: compact intent (under 130 chars) -# - category: domain -# - services: which gws services this recipe uses -# - steps: concrete gws commands -# - caution: optional warning for destructive operations - -recipes: - - - # ============================================================ - # GMAIL WORKFLOWS - # ============================================================ - - name: label-and-archive-emails - title: Label and Archive Gmail Threads - description: "Apply Gmail labels to matching messages and archive them to keep your inbox clean." - category: productivity - services: [gmail] - steps: - - "Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`" - - "Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`" - - "Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`" - - - - - name: draft-email-from-doc - title: Draft a Gmail Message from a Google Doc - description: "Read content from a Google Doc and use it as the body of a Gmail message." - category: productivity - services: [docs, gmail] - steps: - - "Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`" - - "Copy the text from the body content" - - "Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`" - - # ============================================================ - # GOOGLE DRIVE WORKFLOWS - # ============================================================ - - name: organize-drive-folder - title: Organize Files into Google Drive Folders - description: "Create a Google Drive folder structure and move files into the right locations." - category: productivity - services: [drive] - steps: - - "Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`" - - "Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`" - - "Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`" - - "Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`" - - - name: share-folder-with-team - title: Share a Google Drive Folder with a Team - description: "Share a Google Drive folder and all its contents with a list of collaborators." - category: productivity - services: [drive] - steps: - - "Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`" - - "Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`" - - "Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`" - - "Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`" - - - name: email-drive-link - title: Email a Google Drive File Link - description: "Share a Google Drive file and email the link with a message to recipients." - category: productivity - services: [drive, gmail] - steps: - - "Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`" - - "Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`" - - "Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`" - - # ============================================================ - # GOOGLE DOCS WORKFLOWS - # ============================================================ - - name: create-doc-from-template - title: Create a Google Doc from a Template - description: "Copy a Google Docs template, fill in content, and share with collaborators." - category: productivity - services: [drive, docs] - steps: - - "Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`" - - "Get the new doc ID from the response" - - "Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`" - - "Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" - - # ============================================================ - # GOOGLE SHEETS WORKFLOWS - # ============================================================ - - name: create-expense-tracker - title: Create a Google Sheets Expense Tracker - description: "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." - category: productivity - services: [sheets, drive] - steps: - - "Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`" - - "Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`" - - "Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`" - - "Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`" - - - name: copy-sheet-for-new-month - title: Copy a Google Sheet for a New Month - description: "Duplicate a Google Sheets template tab for a new month of tracking." - category: productivity - services: [sheets] - steps: - - "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`" - - "Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`" - - "Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`" - - # ============================================================ - # GOOGLE CALENDAR WORKFLOWS - # ============================================================ - - name: block-focus-time - title: Block Focus Time on Google Calendar - description: "Create recurring focus time blocks on Google Calendar to protect deep work hours." - category: scheduling - services: [calendar] - steps: - - "Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`" - - "Verify it shows as busy: `gws calendar +agenda`" - - - name: reschedule-meeting - title: Reschedule a Google Calendar Meeting - description: "Move a Google Calendar event to a new time and automatically notify all attendees." - category: scheduling - services: [calendar] - steps: - - "Find the event: `gws calendar +agenda`" - - "Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`" - - # ============================================================ - # EMAIL / GMAIL - # ============================================================ - - - - name: create-gmail-filter - title: Create a Gmail Filter - description: "Create a Gmail filter to automatically label, star, or categorize incoming messages." - category: productivity - services: [gmail] - steps: - - "List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`" - - "Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`" - - "Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`" - - "Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`" - - # ============================================================ - # CALENDAR / SCHEDULING - # ============================================================ - - - - name: schedule-recurring-event - title: Schedule a Recurring Meeting - description: "Create a recurring Google Calendar event with attendees." - category: scheduling - services: [calendar] - steps: - - "Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`" - - "Verify it was created: `gws calendar +agenda --days 14 --format table`" - - - name: find-free-time - title: Find Free Time Across Calendars - description: "Query Google Calendar free/busy status for multiple users to find a meeting slot." - category: scheduling - services: [calendar] - steps: - - "Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`" - - "Review the output to find overlapping free slots" - - "Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`" - - # ============================================================ - # DRIVE / FILE MANAGEMENT - # ============================================================ - - name: bulk-download-folder - title: Bulk Download Drive Folder - description: "List and download all files from a Google Drive folder." - category: productivity - services: [drive] - steps: - - "List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`" - - "Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`" - - "Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`" - - - name: find-large-files - title: Find Largest Files in Drive - description: "Identify large Google Drive files consuming storage quota." - category: productivity - services: [drive] - steps: - - "List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`" - - "Review the output and identify files to archive or move" - - - name: create-shared-drive - title: Create and Configure a Shared Drive - description: "Create a Google Shared Drive and add members with appropriate roles." - category: productivity - services: [drive] - steps: - - "Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`" - - "Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`" - - "List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`" - - - - # ============================================================ - # SHEETS / DATA - # ============================================================ - - name: log-deal-update - title: Log Deal Update to Sheet - description: "Append a deal status update to a Google Sheets sales tracking spreadsheet." - category: sales - services: [sheets, drive] - steps: - - "Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`" - - "Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`" - - "Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"$50,000\", \"Q2\", \"jdoe\"]'`" - - - name: collect-form-responses - title: Check Form Responses - description: "Retrieve and review responses from a Google Form." - category: productivity - services: [forms] - steps: - - "List forms: `gws forms forms list` (if you don't have the form ID)" - - "Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`" - - "Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`" - - # ============================================================ - # CHAT / TEAM COMMUNICATION - # ============================================================ - # ============================================================ - # ENGINEERING - # ============================================================ - - name: post-mortem-setup - title: Set Up Post-Mortem - description: "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." - category: engineering - services: [docs, calendar, chat] - steps: - - "Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`" - - "Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`" - - "Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`" - - # ============================================================ - # TASKS - # ============================================================ - - name: create-task-list - title: Create a Task List and Add Tasks - description: "Set up a new Google Tasks list with initial tasks." - category: productivity - services: [tasks] - steps: - - "Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`" - - "Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`" - - "Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`" - - "List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`" - - - name: review-overdue-tasks - title: Review Overdue Tasks - description: "Find Google Tasks that are past due and need attention." - category: productivity - services: [tasks] - steps: - - "List task lists: `gws tasks tasklists list --format table`" - - "List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`" - - "Review due dates and prioritize overdue items" - - # ============================================================ - # CONTACTS / PEOPLE - # ============================================================ - - - # ============================================================ - # EVENT SUBSCRIPTIONS - # ============================================================ - - name: watch-drive-changes - title: Watch for Drive Changes - description: "Subscribe to change notifications on a Google Drive file or folder." - category: engineering - services: [events] - steps: - - "Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`" - - "List active subscriptions: `gws events subscriptions list`" - - "Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`" - - # ============================================================ - # CLASSROOM - # ============================================================ - - name: create-classroom-course - title: Create a Google Classroom Course - description: "Create a Google Classroom course and invite students." - category: education - services: [classroom] - steps: - - "Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`" - - "Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`" - - "List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`" - - # ============================================================ - # MEET - # ============================================================ - - name: create-meet-space - title: Create a Google Meet Conference - description: "Create a Google Meet meeting space and share the join link." - category: scheduling - services: [meet, gmail] - steps: - - "Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`" - - "Copy the meeting URI from the response" - - "Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`" - - - name: review-meet-participants - title: Review Google Meet Attendance - description: "Review who attended a Google Meet conference and for how long." - category: productivity - services: [meet] - steps: - - "List recent conferences: `gws meet conferenceRecords list --format table`" - - "List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`" - - "Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`" - - # ============================================================ - # KEEP - # ============================================================ - # ============================================================ - # SLIDES - # ============================================================ - - name: create-presentation - title: Create a Google Slides Presentation - description: "Create a new Google Slides presentation and add initial slides." - category: productivity - services: [slides] - steps: - - "Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`" - - "Get the presentation ID from the response" - - "Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" - - - - # ============================================================ - # CONSUMER PRODUCTIVITY - # ============================================================ - - name: save-email-attachments - title: Save Gmail Attachments to Google Drive - description: "Find Gmail messages with attachments and save them to a Google Drive folder." - category: productivity - services: [gmail, drive] - steps: - - "Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`" - - "Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`" - - "Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`" - - "Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`" - - # ============================================================ - # CROSS-SERVICE WORKFLOWS - # ============================================================ - - name: send-team-announcement - title: Announce via Gmail and Google Chat - description: "Send a team announcement via both Gmail and a Google Chat space." - category: communication - services: [gmail, chat] - steps: - - "Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`" - - "Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`" - - - name: create-feedback-form - title: Create and Share a Google Form - description: "Create a Google Form for feedback and share it via Gmail." - category: productivity - services: [forms, gmail] - steps: - - "Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`" - - "Get the form URL from the response (responderUri field)" - - "Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`" - - - name: sync-contacts-to-sheet - title: Export Google Contacts to Sheets - description: "Export Google Contacts directory to a Google Sheets spreadsheet." - category: productivity - services: [people, sheets] - steps: - - "List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`" - - "Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`" - - "Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`" - - # ============================================================ - # CONSUMER — COLLABORATION - # ============================================================ - - name: share-event-materials - title: Share Files with Meeting Attendees - description: "Share Google Drive files with all attendees of a Google Calendar event." - category: productivity - services: [calendar, drive] - steps: - - "Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`" - - "Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`" - # ============================================================ - # GMAIL — ORGANIZATION - # ============================================================ - - name: create-vacation-responder - title: Set Up a Gmail Vacation Responder - description: "Enable a Gmail out-of-office auto-reply with a custom message and date range." - category: productivity - services: [gmail] - steps: - - "Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`" - - "Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`" - - "Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`" - - # ============================================================ - # DRIVE — FILE OPERATIONS - # ============================================================ - # ============================================================ - # SHEETS — DATA WORKFLOWS - # ============================================================ - - name: create-events-from-sheet - title: Create Google Calendar Events from a Sheet - description: "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." - category: productivity - services: [sheets, calendar] - steps: - - "Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`" - - "For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`" - - # ============================================================ - # CALENDAR — PLANNING - # ============================================================ - - name: plan-weekly-schedule - title: Plan Your Weekly Google Calendar Schedule - description: "Review your Google Calendar week, identify gaps, and add events to fill them." - category: scheduling - services: [calendar] - steps: - - "Check this week's agenda: `gws calendar +agenda`" - - "Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`" - - "Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`" - - "Review updated schedule: `gws calendar +agenda`" - # ============================================================ - # MULTI-SERVICE PROJECT SETUP - # ============================================================ - # ============================================================ - # DOCS — COLLABORATION - # ============================================================ - - name: share-doc-and-notify - title: Share a Google Doc and Notify Collaborators - description: "Share a Google Docs document with edit access and email collaborators the link." - category: productivity - services: [drive, docs, gmail] - steps: - - "Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`" - - "Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`" - - "Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`" - - # ============================================================ - # SHEETS — BACKUP - # ============================================================ - - name: backup-sheet-as-csv - title: Export a Google Sheet as CSV - description: "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." - category: productivity - services: [sheets, drive] - steps: - - "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`" - - "Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`" - - "Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`" - - # ============================================================ - # CALENDAR — TEAM - # ============================================================ - # ============================================================ - # DRIVE — CLEANUP - # ============================================================ - # ============================================================ - # GMAIL — ARCHIVING - # ============================================================ - - name: save-email-to-doc - title: Save a Gmail Message to Google Docs - description: "Save a Gmail message body into a Google Doc for archival or reference." - category: productivity - services: [gmail, docs] - steps: - - "Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`" - - "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`" - - "Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`" - - "Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`" - - # ============================================================ - # SHEETS — FORMULAS - # ============================================================ - # ============================================================ - # REPETITIVE WORKFLOWS - # ============================================================ - # ============================================================ - # GMAIL — AUTOMATED REPLIES - # ============================================================ - - - # ============================================================ - # DRIVE — BATCH OPERATIONS - # ============================================================ - - - # ============================================================ - # SHEETS — DATA SYNC - # ============================================================ - - name: compare-sheet-tabs - title: Compare Two Google Sheets Tabs - description: "Read data from two tabs in a Google Sheet to compare and identify differences." - category: productivity - services: [sheets] - steps: - - "Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`" - - "Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`" - - "Compare the data and identify changes" - - # ============================================================ - # CALENDAR — COORDINATION - # ============================================================ - - name: batch-invite-to-event - title: Add Multiple Attendees to a Calendar Event - description: "Add a list of attendees to an existing Google Calendar event and send notifications." - category: scheduling - services: [calendar] - steps: - - "Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`" - - "Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - # ============================================================ - # GMAIL — NOTIFICATION ROUTING - # ============================================================ - - name: forward-labeled-emails - title: Forward Labeled Gmail Messages - description: "Find Gmail messages with a specific label and forward them to another address." - category: productivity - services: [gmail] - steps: - - "Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`" - - "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`" - - "Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`" - - # ============================================================ - # DOCS + SHEETS — CROSS-SERVICE - # ============================================================ - - name: generate-report-from-sheet - title: Generate a Google Docs Report from Sheet Data - description: "Read data from a Google Sheet and create a formatted Google Docs report." - category: productivity - services: [sheets, docs, drive] - steps: - - "Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`" - - "Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`" - - "Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: $125,000\n\n### Top Deals\n1. Acme Corp - $25,000\n2. Widget Inc - $18,000'`" - - "Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`" - diff --git a/crates/google-workspace-cli/src/generate_skills.rs b/crates/google-workspace-cli/src/generate_skills.rs index e9d18941..ce265407 100644 --- a/crates/google-workspace-cli/src/generate_skills.rs +++ b/crates/google-workspace-cli/src/generate_skills.rs @@ -24,8 +24,8 @@ use crate::services; use clap::Command; use std::path::Path; -const PERSONAS_YAML: &str = include_str!("../registry/personas.yaml"); -const RECIPES_YAML: &str = include_str!("../registry/recipes.yaml"); +const PERSONAS_TOML: &str = include_str!("../registry/personas.toml"); +const RECIPES_TOML: &str = include_str!("../registry/recipes.toml"); /// Methods blocked from skill generation. /// Format: (service_alias, resource, method). @@ -202,12 +202,11 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } - // Generate Personas if filter .as_ref() .is_none_or(|f| "persona".contains(f.as_str()) || "personas".contains(f.as_str())) { - if let Ok(registry) = serde_yaml::from_str::(PERSONAS_YAML) { + if let Ok(registry) = toml::from_str::(PERSONAS_TOML) { eprintln!( "Generating skills for {} personas...", registry.personas.len() @@ -229,7 +228,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } } else { - eprintln!("WARNING: Failed to parse personas.yaml"); + eprintln!("WARNING: Failed to parse personas.toml"); } } @@ -238,7 +237,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { .as_ref() .is_none_or(|f| "recipe".contains(f.as_str()) || "recipes".contains(f.as_str())) { - if let Ok(registry) = serde_yaml::from_str::(RECIPES_YAML) { + if let Ok(registry) = toml::from_str::(RECIPES_TOML) { eprintln!( "Generating skills for {} recipes...", registry.recipes.len() @@ -260,7 +259,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } } else { - eprintln!("WARNING: Failed to parse recipes.yaml"); + eprintln!("WARNING: Failed to parse recipes.toml"); } } @@ -983,10 +982,8 @@ mod tests { #[test] fn test_registry_references() { - let personas: PersonaRegistry = - serde_yaml::from_str(PERSONAS_YAML).expect("valid personas yaml"); - let recipes: RecipeRegistry = - serde_yaml::from_str(RECIPES_YAML).expect("valid recipes yaml"); + let personas: PersonaRegistry = toml::from_str(PERSONAS_TOML).expect("valid personas toml"); + let recipes: RecipeRegistry = toml::from_str(RECIPES_TOML).expect("valid recipes toml"); // Valid services mapped by api_name or alias let all_services = services::SERVICES; diff --git a/crates/google-workspace-cli/src/helpers/sheets.rs b/crates/google-workspace-cli/src/helpers/sheets.rs index 8f67f8d9..4357edec 100644 --- a/crates/google-workspace-cli/src/helpers/sheets.rs +++ b/crates/google-workspace-cli/src/helpers/sheets.rs @@ -51,14 +51,22 @@ impl Helper for SheetsHelper { .help("JSON array of rows, e.g. '[[\"a\",\"b\"],[\"c\",\"d\"]]'") .value_name("JSON"), ) + .arg( + Arg::new("range") + .long("range") + .help("Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet)") + .value_name("RANGE"), + ) .after_help( r#"EXAMPLES: gws sheets +append --spreadsheet ID --values 'Alice,100,true' gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]' + gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100' TIPS: Use --values for simple single-row appends. - Use --json-values for bulk multi-row inserts."#, + Use --json-values for bulk multi-row inserts. + Use --range to target a specific sheet tab (default: A1, i.e. first sheet)."#, ), ); @@ -212,11 +220,9 @@ fn build_append_request( GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string()) })?; - let range = "A1"; - let params = json!({ "spreadsheetId": config.spreadsheet_id, - "range": range, + "range": config.range, "valueInputOption": "USER_ENTERED" }); @@ -262,6 +268,8 @@ fn build_read_request( pub struct AppendConfig { /// The ID of the spreadsheet to append to. pub spreadsheet_id: String, + /// Target range in A1 notation (e.g. "Sheet2!A1"). Defaults to "A1". + pub range: String, /// The rows to append, where each inner Vec represents one row. pub values: Vec>, } @@ -289,8 +297,14 @@ pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig { Vec::new() }; + let range = matches + .get_one::("range") + .cloned() + .unwrap_or_else(|| "A1".to_string()); + AppendConfig { spreadsheet_id: matches.get_one::("spreadsheet").unwrap().clone(), + range, values, } } @@ -353,7 +367,8 @@ mod tests { let cmd = Command::new("test") .arg(Arg::new("spreadsheet").long("spreadsheet")) .arg(Arg::new("values").long("values")) - .arg(Arg::new("json-values").long("json-values")); + .arg(Arg::new("json-values").long("json-values")) + .arg(Arg::new("range").long("range")); cmd.try_get_matches_from(args).unwrap() } @@ -369,17 +384,32 @@ mod tests { let doc = make_mock_doc(); let config = AppendConfig { spreadsheet_id: "123".to_string(), + range: "A1".to_string(), values: vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]], }; let (params, body, scopes) = build_append_request(&config, &doc).unwrap(); assert!(params.contains("123")); assert!(params.contains("USER_ENTERED")); + assert!(params.contains("A1")); assert!(body.contains("a")); assert!(body.contains("b")); assert_eq!(scopes[0], "https://scope"); } + #[test] + fn test_build_append_request_with_range() { + let doc = make_mock_doc(); + let config = AppendConfig { + spreadsheet_id: "123".to_string(), + range: "Sheet2!A1".to_string(), + values: vec![vec!["x".to_string()]], + }; + let (params, _body, _scopes) = build_append_request(&config, &doc).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(¶ms).unwrap(); + assert_eq!(parsed["range"], "Sheet2!A1"); + } + #[test] fn test_build_read_request() { let doc = make_mock_doc(); @@ -399,9 +429,32 @@ mod tests { let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a,b,c"]); let config = parse_append_args(&matches); assert_eq!(config.spreadsheet_id, "123"); + assert_eq!(config.range, "A1"); assert_eq!(config.values, vec![vec!["a", "b", "c"]]); } + #[test] + fn test_parse_append_args_with_range() { + let matches = make_matches_append(&[ + "test", + "--spreadsheet", + "123", + "--range", + "Sheet2!A1", + "--values", + "a,b", + ]); + let config = parse_append_args(&matches); + assert_eq!(config.range, "Sheet2!A1"); + } + + #[test] + fn test_parse_append_args_default_range() { + let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a"]); + let config = parse_append_args(&matches); + assert_eq!(config.range, "A1"); + } + #[test] fn test_parse_append_args_json_single_row() { let matches = make_matches_append(&[ @@ -436,6 +489,7 @@ mod tests { let doc = make_mock_doc(); let config = AppendConfig { spreadsheet_id: "123".to_string(), + range: "A1".to_string(), values: vec![ vec!["Alice".to_string(), "100".to_string()], vec!["Bob".to_string(), "200".to_string()], diff --git a/crates/google-workspace/Cargo.toml b/crates/google-workspace/Cargo.toml index 22db22bd..3dc9f083 100644 --- a/crates/google-workspace/Cargo.toml +++ b/crates/google-workspace/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "google-workspace" -version = "0.22.3" +version = "0.22.5" edition = "2021" description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities" license = "Apache-2.0" diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..ba5b337f --- /dev/null +++ b/deny.toml @@ -0,0 +1,49 @@ +# cargo-deny configuration +# https://embarkstudios.github.io/cargo-deny/ + +[graph] +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +] + +# Advisories — checks for known vulnerable crate versions +[advisories] +ignore = [] +db-urls = ["https://github.com/rustsec/advisory-db"] + +# Licenses — allowlist of acceptable licenses +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "OpenSSL", + "MPL-2.0", + "CC0-1.0", + "BSL-1.0", +] + +# Bans — reject problematic and duplicate crates +[bans] +multiple-versions = "warn" +wildcards = "deny" +deny = [] + +# Sources — restrict where crates can come from +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index", "https://index.crates.io/"] +allow-git = [] diff --git a/dist-workspace.toml b/dist-workspace.toml deleted file mode 100644 index aee41cee..00000000 --- a/dist-workspace.toml +++ /dev/null @@ -1,44 +0,0 @@ -# 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:crates/google-workspace-cli"] - -# 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"] -npm-scope = "@googleworkspace" -# Enable github attestations -github-attestations = true -npm-package = "cli" -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] -# Which actions to run on pull requests -pr-run-mode = "plan" -# Don't overwrite release.yml on `cargo dist init` (preserves custom npm registry config) -allow-dirty = ["ci"] -# The archive format to use for windows builds (defaults .zip) -# Using .zip routes through PowerShell's Expand-Archive, which correctly -# handles Windows paths. Using .tar.gz causes failures in Git Bash because -# MSYS tar interprets "C:" as a remote host (issue #152). -windows-archive = ".zip" -# The archive format to use for non-windows builds (defaults .tar.xz) -unix-archive = ".tar.gz" diff --git a/flake.lock b/flake.lock index f1943347..f123c7ec 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774386573, - "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", + "lastModified": 1774709303, + "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", + "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", "type": "github" }, "original": { diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 00000000..3c95b725 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,2 @@ +# Downloaded binary (created during npm postinstall) +bin/ diff --git a/npm/install.js b/npm/install.js new file mode 100644 index 00000000..614b44a7 --- /dev/null +++ b/npm/install.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { pipeline } = require("stream/promises"); +const { createWriteStream, mkdirSync, rmSync } = require("fs"); +const { spawnSync } = require("child_process"); +const { getPlatform } = require("./platform"); + +const INSTALL_DIR = path.join(__dirname, "bin"); + +/** + * Get the GitHub release download URL base for the current package version. + */ +function getDownloadUrl(artifactName) { + const { version } = require("./package.json"); + return `https://github.com/googleworkspace/cli/releases/download/v${version}/${artifactName}`; +} + +/** + * Strip ANSI escape sequences from a string. + */ +function sanitize(str) { + // eslint-disable-next-line no-control-regex + return String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +/** + * Download a file using native fetch (Node 18+). + * + * NOTE: Native fetch does not respect HTTP_PROXY / HTTPS_PROXY environment + * variables. If proxy support is needed, consider using the `undici` ProxyAgent + * or a Node.js build with proxy support. + */ +async function download(url, dest) { + const res = await fetch(url, { redirect: "follow" }); + + if (!res.ok) { + throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); + } + + if (!res.body) { + throw new Error(`Failed to download ${url}: Response body is empty`); + } + + const fileStream = createWriteStream(dest); + // Convert web ReadableStream to Node stream and pipe + const { Readable } = require("stream"); + const nodeStream = Readable.fromWeb(res.body); + await pipeline(nodeStream, fileStream); +} + +/** + * Run a command and throw on failure. + */ +function run(cmd, args) { + const result = spawnSync(cmd, args, { stdio: "pipe" }); + if (result.error) { + throw new Error(`Failed to run ${cmd}: ${result.error.message}`); + } + if ((result.status ?? 1) !== 0) { + const stderr = result.stderr ? result.stderr.toString() : ""; + throw new Error( + `Command failed: ${cmd} ${args.join(" ")}\n${stderr}`, + ); + } +} + +/** + * Extract the archive to the install directory. + */ +function extract(archivePath, destDir) { + const isZip = archivePath.endsWith(".zip"); + const isTar = archivePath.includes(".tar."); + + if (isTar) { + run("tar", ["xf", archivePath, "-C", destDir]); + } else if (isZip) { + if (process.platform === "win32") { + // Use single-quoted PowerShell strings with doubled single-quote escaping + // to safely handle paths containing spaces and special characters. + const psArchive = archivePath.replace(/'/g, "''"); + const psDest = destDir.replace(/'/g, "''"); + run("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath '${psArchive}' -DestinationPath '${psDest}' -Force`, + ]); + } else { + run("unzip", ["-q", "-o", archivePath, "-d", destDir]); + } + } else { + throw new Error(`Unsupported archive format: ${archivePath}`); + } +} + +async function install() { + const platform = getPlatform(); + const { version } = require("./package.json"); + const url = getDownloadUrl(platform.artifact); + + // Check if the correct version is already installed + const binPath = path.join(INSTALL_DIR, platform.binary); + const versionFile = path.join(INSTALL_DIR, ".version"); + if (fs.existsSync(binPath) && fs.existsSync(versionFile)) { + const installed = fs.readFileSync(versionFile, "utf8").trim(); + if (installed === version) { + console.error(`gws v${version} is already installed, skipping.`); + return; + } + console.error(`Upgrading gws from v${installed} to v${version}`); + } + + // Clean and create install directory + if (fs.existsSync(INSTALL_DIR)) { + rmSync(INSTALL_DIR, { recursive: true, force: true }); + } + mkdirSync(INSTALL_DIR, { recursive: true }); + + // Download to a temp file + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gws-")); + const archiveName = path.basename(platform.artifact); + const tmpFile = path.join(tmpDir, archiveName); + + try { + console.error(`Downloading gws from ${url}`); + await download(url, tmpFile); + + // Verify SHA256 checksum + const sha256Url = `${url}.sha256`; + const sha256File = `${tmpFile}.sha256`; + console.error(`Verifying checksum from ${sha256Url}`); + await download(sha256Url, sha256File); + + const expectedHash = fs.readFileSync(sha256File, "utf8").trim().split(/\s+/)[0].toLowerCase(); + const fileBuffer = fs.readFileSync(tmpFile); + const actualHash = crypto.createHash("sha256").update(fileBuffer).digest("hex").toLowerCase(); + + if (actualHash !== expectedHash) { + throw new Error( + `SHA256 checksum mismatch!\n Expected: ${expectedHash}\n Actual: ${actualHash}\nThe downloaded binary may have been tampered with.`, + ); + } + console.error("Checksum verified ✓"); + + console.error(`Extracting to ${INSTALL_DIR}`); + extract(tmpFile, INSTALL_DIR); + + // Make binary executable on Unix + if (process.platform !== "win32") { + fs.chmodSync(binPath, 0o755); + } + + console.error(`gws v${version} has been installed!`); + fs.writeFileSync(versionFile, version); + } finally { + // Clean up temp files + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +install().catch((err) => { + console.error(`Error installing gws: ${sanitize(err.message)}`); + process.exit(1); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 00000000..46293ffa --- /dev/null +++ b/npm/package.json @@ -0,0 +1,79 @@ +{ + "name": "@googleworkspace/cli", + "description": "Google Workspace CLI — dynamic command surface from Discovery Service", + "version": "0.22.5", + "license": "Apache-2.0", + "author": "Justin Poehnelt", + "repository": { + "type": "git", + "url": "https://github.com/googleworkspace/cli.git" + }, + "homepage": "https://github.com/googleworkspace/cli", + "bugs": { + "url": "https://github.com/googleworkspace/cli/issues" + }, + "bin": { + "gws": "run.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "engines": { + "node": ">=18" + }, + "preferUnplugged": true, + "keywords": [ + "cli", + "google-workspace", + "google", + "google-api", + "google-drive", + "google-gmail", + "google-sheets", + "google-calendar", + "google-docs", + "google-chat", + "google-admin", + "gsuite", + "discovery-api", + "ai-agent", + "agent-skills", + "automation", + "oauth2", + "rust" + ], + "publishConfig": { + "provenance": true, + "registry": "https://wombat-dressing-room.appspot.com" + }, + "supportedPlatforms": { + "aarch64-apple-darwin": { + "artifact": "google-workspace-cli-aarch64-apple-darwin.tar.gz", + "binary": "gws" + }, + "x86_64-apple-darwin": { + "artifact": "google-workspace-cli-x86_64-apple-darwin.tar.gz", + "binary": "gws" + }, + "aarch64-unknown-linux-gnu": { + "artifact": "google-workspace-cli-aarch64-unknown-linux-gnu.tar.gz", + "binary": "gws" + }, + "aarch64-unknown-linux-musl": { + "artifact": "google-workspace-cli-aarch64-unknown-linux-musl.tar.gz", + "binary": "gws" + }, + "x86_64-unknown-linux-gnu": { + "artifact": "google-workspace-cli-x86_64-unknown-linux-gnu.tar.gz", + "binary": "gws" + }, + "x86_64-unknown-linux-musl": { + "artifact": "google-workspace-cli-x86_64-unknown-linux-musl.tar.gz", + "binary": "gws" + }, + "x86_64-pc-windows-msvc": { + "artifact": "google-workspace-cli-x86_64-pc-windows-msvc.zip", + "binary": "gws.exe" + } + } +} diff --git a/npm/platform.js b/npm/platform.js new file mode 100644 index 00000000..cf90d471 --- /dev/null +++ b/npm/platform.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +"use strict"; + +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); + +const { supportedPlatforms } = require("./package.json"); + +/** + * Map Node.js os.type() and os.arch() to Rust-style target triples. + */ +function getPlatformKey() { + const rawOs = os.type(); + const rawArch = os.arch(); + + let osType; + switch (rawOs) { + case "Windows_NT": + osType = "pc-windows-msvc"; + break; + case "Darwin": + osType = "apple-darwin"; + break; + case "Linux": + osType = "unknown-linux-gnu"; + break; + default: + throw new Error(`Unsupported operating system: ${rawOs}`); + } + + let arch; + switch (rawArch) { + case "x64": + arch = "x86_64"; + break; + case "arm64": + arch = "aarch64"; + break; + default: + throw new Error(`Unsupported architecture: ${rawArch}`); + } + + // On Linux, try to detect musl libc + if (rawOs === "Linux") { + try { + const result = spawnSync("ldd", ["--version"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + // musl ldd prints version info to stderr + const output = (result.stdout || "") + (result.stderr || ""); + if (output.toLowerCase().includes("musl")) { + osType = "unknown-linux-musl"; + } + } catch { + // If ldd fails, assume glibc + } + } + + const key = `${arch}-${osType}`; + + if (!supportedPlatforms[key]) { + // Try musl fallback on Linux if glibc binary is not available + if (rawOs === "Linux") { + const muslKey = `${arch}-unknown-linux-musl`; + if (supportedPlatforms[muslKey]) { + return muslKey; + } + } + throw new Error( + `Unsupported platform: ${key}\nSupported platforms: ${Object.keys(supportedPlatforms).join(", ")}`, + ); + } + + return key; +} + +function getPlatform() { + const key = getPlatformKey(); + return supportedPlatforms[key]; +} + +module.exports = { getPlatform, getPlatformKey }; diff --git a/npm/run.js b/npm/run.js new file mode 100644 index 00000000..4d6e84db --- /dev/null +++ b/npm/run.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { getPlatform } = require("./platform"); + +const platform = getPlatform(); +const binPath = path.join(__dirname, "bin", platform.binary); + +if (!fs.existsSync(binPath)) { + console.error( + `gws binary not found at ${binPath}\nAuto-installing...` + ); + const install = spawnSync(process.execPath, [path.join(__dirname, "install.js")], { + cwd: __dirname, + stdio: "inherit", + }); + if (install.status !== 0) { + process.exit(install.status ?? 1); + } +} + +const result = spawnSync(binPath, process.argv.slice(2), { + cwd: process.cwd(), + stdio: "inherit", +}); + +if (result.error) { + console.error(`Error running gws: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/package.json b/package.json index 9d923e36..2a556aad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@googleworkspace/cli", - "version": "0.22.3", + "version": "0.22.5", "private": true, "description": "Google Workspace CLI — dynamic command surface from Discovery Service", "license": "Apache-2.0", diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 385e04d1..4dd83ec2 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -26,6 +26,13 @@ done sed -i.bak -E "s/(google-workspace = \{ version = \")[^\"]+/\1${VERSION}/" crates/google-workspace-cli/Cargo.toml rm -f crates/google-workspace-cli/Cargo.toml.bak +# Update npm installer package.json version +node -e " + const pkg = require('./npm/package.json'); + pkg.version = '${VERSION}'; + require('fs').writeFileSync('./npm/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + # Update Cargo.lock to match cargo generate-lockfile @@ -38,5 +45,5 @@ fi cargo run -- generate-skills --output-dir skills # Stage the changed files so changesets/action commits them -git add crates/*/Cargo.toml Cargo.lock flake.nix flake.lock skills/ +git add crates/*/Cargo.toml Cargo.lock flake.nix flake.lock skills/ npm/package.json diff --git a/skills/gws-admin-reports/SKILL.md b/skills/gws-admin-reports/SKILL.md index 9a37bfa2..4c9eae17 100644 --- a/skills/gws-admin-reports/SKILL.md +++ b/skills/gws-admin-reports/SKILL.md @@ -2,7 +2,7 @@ name: gws-admin-reports description: "Google Workspace Admin SDK: Audit logs and usage reports." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar-agenda/SKILL.md b/skills/gws-calendar-agenda/SKILL.md index 12819b7b..89259ff3 100644 --- a/skills/gws-calendar-agenda/SKILL.md +++ b/skills/gws-calendar-agenda/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar-agenda description: "Google Calendar: Show upcoming events across all calendars." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar-insert/SKILL.md b/skills/gws-calendar-insert/SKILL.md index 076388aa..dd2989d8 100644 --- a/skills/gws-calendar-insert/SKILL.md +++ b/skills/gws-calendar-insert/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar-insert description: "Google Calendar: Create a new event." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar/SKILL.md b/skills/gws-calendar/SKILL.md index 9419fbbe..08e134ce 100644 --- a/skills/gws-calendar/SKILL.md +++ b/skills/gws-calendar/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar description: "Google Calendar: Manage calendars and events." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-chat-send/SKILL.md b/skills/gws-chat-send/SKILL.md index ebfdeaab..3ac2e237 100644 --- a/skills/gws-chat-send/SKILL.md +++ b/skills/gws-chat-send/SKILL.md @@ -2,7 +2,7 @@ name: gws-chat-send description: "Google Chat: Send a message to a space." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-chat/SKILL.md b/skills/gws-chat/SKILL.md index 27863110..5eaca50c 100644 --- a/skills/gws-chat/SKILL.md +++ b/skills/gws-chat/SKILL.md @@ -2,7 +2,7 @@ name: gws-chat description: "Google Chat: Manage Chat spaces and messages." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-classroom/SKILL.md b/skills/gws-classroom/SKILL.md index 5bd86b7b..466a7105 100644 --- a/skills/gws-classroom/SKILL.md +++ b/skills/gws-classroom/SKILL.md @@ -2,7 +2,7 @@ name: gws-classroom description: "Google Classroom: Manage classes, rosters, and coursework." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-docs-write/SKILL.md b/skills/gws-docs-write/SKILL.md index 32925507..4c903126 100644 --- a/skills/gws-docs-write/SKILL.md +++ b/skills/gws-docs-write/SKILL.md @@ -2,7 +2,7 @@ name: gws-docs-write description: "Google Docs: Append text to a document." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-docs/SKILL.md b/skills/gws-docs/SKILL.md index 0f4cc94c..f51b1950 100644 --- a/skills/gws-docs/SKILL.md +++ b/skills/gws-docs/SKILL.md @@ -2,7 +2,7 @@ name: gws-docs description: "Read and write Google Docs." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-drive-upload/SKILL.md b/skills/gws-drive-upload/SKILL.md index a5c05d86..fe2b4017 100644 --- a/skills/gws-drive-upload/SKILL.md +++ b/skills/gws-drive-upload/SKILL.md @@ -2,7 +2,7 @@ name: gws-drive-upload description: "Google Drive: Upload a file with automatic metadata." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-drive/SKILL.md b/skills/gws-drive/SKILL.md index ec0536f7..d4debbdb 100644 --- a/skills/gws-drive/SKILL.md +++ b/skills/gws-drive/SKILL.md @@ -2,7 +2,7 @@ name: gws-drive description: "Google Drive: Manage files, folders, and shared drives." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-events-renew/SKILL.md b/skills/gws-events-renew/SKILL.md index 6a127c11..bb825ed7 100644 --- a/skills/gws-events-renew/SKILL.md +++ b/skills/gws-events-renew/SKILL.md @@ -2,7 +2,7 @@ name: gws-events-renew description: "Google Workspace Events: Renew/reactivate Workspace Events subscriptions." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-events-subscribe/SKILL.md b/skills/gws-events-subscribe/SKILL.md index 8963dcbe..f2e45e90 100644 --- a/skills/gws-events-subscribe/SKILL.md +++ b/skills/gws-events-subscribe/SKILL.md @@ -2,7 +2,7 @@ name: gws-events-subscribe description: "Google Workspace Events: Subscribe to Workspace events and stream them as NDJSON." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-events/SKILL.md b/skills/gws-events/SKILL.md index 9301f4f6..e72f5124 100644 --- a/skills/gws-events/SKILL.md +++ b/skills/gws-events/SKILL.md @@ -2,7 +2,7 @@ name: gws-events description: "Subscribe to Google Workspace events." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-forms/SKILL.md b/skills/gws-forms/SKILL.md index 0c2424a1..3a11a94e 100644 --- a/skills/gws-forms/SKILL.md +++ b/skills/gws-forms/SKILL.md @@ -2,7 +2,7 @@ name: gws-forms description: "Read and write Google Forms." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md index ee2020b8..d433cef5 100644 --- a/skills/gws-gmail-forward/SKILL.md +++ b/skills/gws-gmail-forward/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-forward description: "Gmail: Forward a message to new recipients." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-read/SKILL.md b/skills/gws-gmail-read/SKILL.md index 3a6a1cad..dc153af4 100644 --- a/skills/gws-gmail-read/SKILL.md +++ b/skills/gws-gmail-read/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-read description: "Gmail: Read a message and extract its body or headers." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md index 9d5684c8..1a4e4c8b 100644 --- a/skills/gws-gmail-reply-all/SKILL.md +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-reply-all description: "Gmail: Reply-all to a message (handles threading automatically)." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md index 2e56dc48..e3538827 100644 --- a/skills/gws-gmail-reply/SKILL.md +++ b/skills/gws-gmail-reply/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-reply description: "Gmail: Reply to a message (handles threading automatically)." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-send/SKILL.md b/skills/gws-gmail-send/SKILL.md index cf2721a3..55da9c28 100644 --- a/skills/gws-gmail-send/SKILL.md +++ b/skills/gws-gmail-send/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-send description: "Gmail: Send an email." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-triage/SKILL.md b/skills/gws-gmail-triage/SKILL.md index ae3fba07..641a2d11 100644 --- a/skills/gws-gmail-triage/SKILL.md +++ b/skills/gws-gmail-triage/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-triage description: "Gmail: Show unread inbox summary (sender, subject, date)." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-watch/SKILL.md b/skills/gws-gmail-watch/SKILL.md index caff6205..fca780f7 100644 --- a/skills/gws-gmail-watch/SKILL.md +++ b/skills/gws-gmail-watch/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-watch description: "Gmail: Watch for new emails and stream them as NDJSON." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail/SKILL.md b/skills/gws-gmail/SKILL.md index 80c75efa..3423770e 100644 --- a/skills/gws-gmail/SKILL.md +++ b/skills/gws-gmail/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail description: "Gmail: Send, read, and manage email." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-keep/SKILL.md b/skills/gws-keep/SKILL.md index 7f148161..8c2d0bf4 100644 --- a/skills/gws-keep/SKILL.md +++ b/skills/gws-keep/SKILL.md @@ -2,7 +2,7 @@ name: gws-keep description: "Manage Google Keep notes." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-meet/SKILL.md b/skills/gws-meet/SKILL.md index 4c902127..6959c2bd 100644 --- a/skills/gws-meet/SKILL.md +++ b/skills/gws-meet/SKILL.md @@ -2,7 +2,7 @@ name: gws-meet description: "Manage Google Meet conferences." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-modelarmor-create-template/SKILL.md b/skills/gws-modelarmor-create-template/SKILL.md index e1181d8d..3926280e 100644 --- a/skills/gws-modelarmor-create-template/SKILL.md +++ b/skills/gws-modelarmor-create-template/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-create-template description: "Google Model Armor: Create a new Model Armor template." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor-sanitize-prompt/SKILL.md b/skills/gws-modelarmor-sanitize-prompt/SKILL.md index bb2add78..cff9dde9 100644 --- a/skills/gws-modelarmor-sanitize-prompt/SKILL.md +++ b/skills/gws-modelarmor-sanitize-prompt/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-sanitize-prompt description: "Google Model Armor: Sanitize a user prompt through a Model Armor template." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor-sanitize-response/SKILL.md b/skills/gws-modelarmor-sanitize-response/SKILL.md index b2187501..b4cd60fa 100644 --- a/skills/gws-modelarmor-sanitize-response/SKILL.md +++ b/skills/gws-modelarmor-sanitize-response/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-sanitize-response description: "Google Model Armor: Sanitize a model response through a Model Armor template." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor/SKILL.md b/skills/gws-modelarmor/SKILL.md index d6a805fc..15efca47 100644 --- a/skills/gws-modelarmor/SKILL.md +++ b/skills/gws-modelarmor/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor description: "Google Model Armor: Filter user-generated content for safety." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-people/SKILL.md b/skills/gws-people/SKILL.md index 46e00cc3..06527a73 100644 --- a/skills/gws-people/SKILL.md +++ b/skills/gws-people/SKILL.md @@ -2,7 +2,7 @@ name: gws-people description: "Google People: Manage contacts and profiles." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-script-push/SKILL.md b/skills/gws-script-push/SKILL.md index 3b26dddf..c3920af2 100644 --- a/skills/gws-script-push/SKILL.md +++ b/skills/gws-script-push/SKILL.md @@ -2,7 +2,7 @@ name: gws-script-push description: "Google Apps Script: Upload local files to an Apps Script project." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-script/SKILL.md b/skills/gws-script/SKILL.md index 6307c146..e7e288aa 100644 --- a/skills/gws-script/SKILL.md +++ b/skills/gws-script/SKILL.md @@ -2,7 +2,7 @@ name: gws-script description: "Manage Google Apps Script projects." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-shared/SKILL.md b/skills/gws-shared/SKILL.md index f360214a..dc6a3223 100644 --- a/skills/gws-shared/SKILL.md +++ b/skills/gws-shared/SKILL.md @@ -2,7 +2,7 @@ name: gws-shared description: "gws CLI: Shared patterns for authentication, global flags, and output formatting." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-sheets-append/SKILL.md b/skills/gws-sheets-append/SKILL.md index 51400361..cac70a28 100644 --- a/skills/gws-sheets-append/SKILL.md +++ b/skills/gws-sheets-append/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets-append description: "Google Sheets: Append a row to a spreadsheet." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: @@ -30,18 +30,21 @@ gws sheets +append --spreadsheet | `--spreadsheet` | ✓ | — | Spreadsheet ID | | `--values` | — | — | Comma-separated values (simple strings) | | `--json-values` | — | — | JSON array of rows, e.g. '[["a","b"],["c","d"]]' | +| `--range` | — | `A1` | Target range in A1 notation (e.g. 'Sheet2!A1') to select a specific tab | ## Examples ```bash gws sheets +append --spreadsheet ID --values 'Alice,100,true' gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]' +gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100' ``` ## Tips - Use --values for simple single-row appends. - Use --json-values for bulk multi-row inserts. +- Use --range to append to a specific sheet tab (default: A1, i.e. first sheet). > [!CAUTION] > This is a **write** command — confirm with the user before executing. diff --git a/skills/gws-sheets-read/SKILL.md b/skills/gws-sheets-read/SKILL.md index 965f0503..a1711dc3 100644 --- a/skills/gws-sheets-read/SKILL.md +++ b/skills/gws-sheets-read/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets-read description: "Google Sheets: Read values from a spreadsheet." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-sheets/SKILL.md b/skills/gws-sheets/SKILL.md index b006d3fa..d713253d 100644 --- a/skills/gws-sheets/SKILL.md +++ b/skills/gws-sheets/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets description: "Google Sheets: Read and write spreadsheets." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-slides/SKILL.md b/skills/gws-slides/SKILL.md index 7bcbab97..afa06f97 100644 --- a/skills/gws-slides/SKILL.md +++ b/skills/gws-slides/SKILL.md @@ -2,7 +2,7 @@ name: gws-slides description: "Google Slides: Read and write presentations." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-tasks/SKILL.md b/skills/gws-tasks/SKILL.md index 2a697549..da4b44d1 100644 --- a/skills/gws-tasks/SKILL.md +++ b/skills/gws-tasks/SKILL.md @@ -2,7 +2,7 @@ name: gws-tasks description: "Google Tasks: Manage task lists and tasks." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-email-to-task/SKILL.md b/skills/gws-workflow-email-to-task/SKILL.md index 2ff4bff7..bf9456d7 100644 --- a/skills/gws-workflow-email-to-task/SKILL.md +++ b/skills/gws-workflow-email-to-task/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-email-to-task description: "Google Workflow: Convert a Gmail message into a Google Tasks entry." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-file-announce/SKILL.md b/skills/gws-workflow-file-announce/SKILL.md index b586bd5e..0937cd68 100644 --- a/skills/gws-workflow-file-announce/SKILL.md +++ b/skills/gws-workflow-file-announce/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-file-announce description: "Google Workflow: Announce a Drive file in a Chat space." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-meeting-prep/SKILL.md b/skills/gws-workflow-meeting-prep/SKILL.md index 379ea684..f0edea82 100644 --- a/skills/gws-workflow-meeting-prep/SKILL.md +++ b/skills/gws-workflow-meeting-prep/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-meeting-prep description: "Google Workflow: Prepare for your next meeting: agenda, attendees, and linked docs." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-standup-report/SKILL.md b/skills/gws-workflow-standup-report/SKILL.md index a91cea42..9b1a8c91 100644 --- a/skills/gws-workflow-standup-report/SKILL.md +++ b/skills/gws-workflow-standup-report/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-standup-report description: "Google Workflow: Today's meetings + open tasks as a standup summary." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-weekly-digest/SKILL.md b/skills/gws-workflow-weekly-digest/SKILL.md index 7413c56d..f07df867 100644 --- a/skills/gws-workflow-weekly-digest/SKILL.md +++ b/skills/gws-workflow-weekly-digest/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-weekly-digest description: "Google Workflow: Weekly summary: this week's meetings + unread email count." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow/SKILL.md b/skills/gws-workflow/SKILL.md index 3b8a3770..4078e159 100644 --- a/skills/gws-workflow/SKILL.md +++ b/skills/gws-workflow/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow description: "Google Workflow: Cross-service productivity workflows." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "productivity" requires: diff --git a/skills/persona-content-creator/SKILL.md b/skills/persona-content-creator/SKILL.md index b4632863..f9846fc6 100644 --- a/skills/persona-content-creator/SKILL.md +++ b/skills/persona-content-creator/SKILL.md @@ -2,7 +2,7 @@ name: persona-content-creator description: "Create, organize, and distribute content across Workspace." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-customer-support/SKILL.md b/skills/persona-customer-support/SKILL.md index 59344a77..7d777cdb 100644 --- a/skills/persona-customer-support/SKILL.md +++ b/skills/persona-customer-support/SKILL.md @@ -2,7 +2,7 @@ name: persona-customer-support description: "Manage customer support — track tickets, respond, escalate issues." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-event-coordinator/SKILL.md b/skills/persona-event-coordinator/SKILL.md index 1c618624..a6a63e2f 100644 --- a/skills/persona-event-coordinator/SKILL.md +++ b/skills/persona-event-coordinator/SKILL.md @@ -2,7 +2,7 @@ name: persona-event-coordinator description: "Plan and manage events — scheduling, invitations, and logistics." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-exec-assistant/SKILL.md b/skills/persona-exec-assistant/SKILL.md index e8bfc129..8e0dc860 100644 --- a/skills/persona-exec-assistant/SKILL.md +++ b/skills/persona-exec-assistant/SKILL.md @@ -2,7 +2,7 @@ name: persona-exec-assistant description: "Manage an executive's schedule, inbox, and communications." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-hr-coordinator/SKILL.md b/skills/persona-hr-coordinator/SKILL.md index 2ed86883..b0fd1187 100644 --- a/skills/persona-hr-coordinator/SKILL.md +++ b/skills/persona-hr-coordinator/SKILL.md @@ -2,7 +2,7 @@ name: persona-hr-coordinator description: "Handle HR workflows — onboarding, announcements, and employee comms." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-it-admin/SKILL.md b/skills/persona-it-admin/SKILL.md index 2f44996d..270fdeff 100644 --- a/skills/persona-it-admin/SKILL.md +++ b/skills/persona-it-admin/SKILL.md @@ -2,7 +2,7 @@ name: persona-it-admin description: "Administer IT — monitor security and configure Workspace." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-project-manager/SKILL.md b/skills/persona-project-manager/SKILL.md index c46060d2..995872c1 100644 --- a/skills/persona-project-manager/SKILL.md +++ b/skills/persona-project-manager/SKILL.md @@ -2,7 +2,7 @@ name: persona-project-manager description: "Coordinate projects — track tasks, schedule meetings, and share docs." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-researcher/SKILL.md b/skills/persona-researcher/SKILL.md index 5e8a90cc..ef0068cb 100644 --- a/skills/persona-researcher/SKILL.md +++ b/skills/persona-researcher/SKILL.md @@ -2,7 +2,7 @@ name: persona-researcher description: "Organize research — manage references, notes, and collaboration." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-sales-ops/SKILL.md b/skills/persona-sales-ops/SKILL.md index f8a1f4e3..b22f7552 100644 --- a/skills/persona-sales-ops/SKILL.md +++ b/skills/persona-sales-ops/SKILL.md @@ -2,7 +2,7 @@ name: persona-sales-ops description: "Manage sales workflows — track deals, schedule calls, client comms." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/persona-team-lead/SKILL.md b/skills/persona-team-lead/SKILL.md index 4db23333..46d3e33a 100644 --- a/skills/persona-team-lead/SKILL.md +++ b/skills/persona-team-lead/SKILL.md @@ -2,7 +2,7 @@ name: persona-team-lead description: "Lead a team — run standups, coordinate tasks, and communicate." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "persona" requires: diff --git a/skills/recipe-backup-sheet-as-csv/SKILL.md b/skills/recipe-backup-sheet-as-csv/SKILL.md index 23af4089..8adc1d00 100644 --- a/skills/recipe-backup-sheet-as-csv/SKILL.md +++ b/skills/recipe-backup-sheet-as-csv/SKILL.md @@ -2,7 +2,7 @@ name: recipe-backup-sheet-as-csv description: "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-batch-invite-to-event/SKILL.md b/skills/recipe-batch-invite-to-event/SKILL.md index df05a9ba..c811d150 100644 --- a/skills/recipe-batch-invite-to-event/SKILL.md +++ b/skills/recipe-batch-invite-to-event/SKILL.md @@ -2,7 +2,7 @@ name: recipe-batch-invite-to-event description: "Add a list of attendees to an existing Google Calendar event and send notifications." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-block-focus-time/SKILL.md b/skills/recipe-block-focus-time/SKILL.md index a1bbc2bb..6ac952e8 100644 --- a/skills/recipe-block-focus-time/SKILL.md +++ b/skills/recipe-block-focus-time/SKILL.md @@ -2,7 +2,7 @@ name: recipe-block-focus-time description: "Create recurring focus time blocks on Google Calendar to protect deep work hours." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-bulk-download-folder/SKILL.md b/skills/recipe-bulk-download-folder/SKILL.md index 02a6f0e7..261be02a 100644 --- a/skills/recipe-bulk-download-folder/SKILL.md +++ b/skills/recipe-bulk-download-folder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-bulk-download-folder description: "List and download all files from a Google Drive folder." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-collect-form-responses/SKILL.md b/skills/recipe-collect-form-responses/SKILL.md index 208623c7..50e44d38 100644 --- a/skills/recipe-collect-form-responses/SKILL.md +++ b/skills/recipe-collect-form-responses/SKILL.md @@ -2,7 +2,7 @@ name: recipe-collect-form-responses description: "Retrieve and review responses from a Google Form." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-compare-sheet-tabs/SKILL.md b/skills/recipe-compare-sheet-tabs/SKILL.md index badae6ec..d5b68317 100644 --- a/skills/recipe-compare-sheet-tabs/SKILL.md +++ b/skills/recipe-compare-sheet-tabs/SKILL.md @@ -2,7 +2,7 @@ name: recipe-compare-sheet-tabs description: "Read data from two tabs in a Google Sheet to compare and identify differences." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-copy-sheet-for-new-month/SKILL.md b/skills/recipe-copy-sheet-for-new-month/SKILL.md index 8ff0f40e..31ac231c 100644 --- a/skills/recipe-copy-sheet-for-new-month/SKILL.md +++ b/skills/recipe-copy-sheet-for-new-month/SKILL.md @@ -2,7 +2,7 @@ name: recipe-copy-sheet-for-new-month description: "Duplicate a Google Sheets template tab for a new month of tracking." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-classroom-course/SKILL.md b/skills/recipe-create-classroom-course/SKILL.md index 99e7d1d9..0e7b08fe 100644 --- a/skills/recipe-create-classroom-course/SKILL.md +++ b/skills/recipe-create-classroom-course/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-classroom-course description: "Create a Google Classroom course and invite students." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "education" diff --git a/skills/recipe-create-doc-from-template/SKILL.md b/skills/recipe-create-doc-from-template/SKILL.md index f0cb7b71..4e9312fb 100644 --- a/skills/recipe-create-doc-from-template/SKILL.md +++ b/skills/recipe-create-doc-from-template/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-doc-from-template description: "Copy a Google Docs template, fill in content, and share with collaborators." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-events-from-sheet/SKILL.md b/skills/recipe-create-events-from-sheet/SKILL.md index 841c60f2..ba248e2f 100644 --- a/skills/recipe-create-events-from-sheet/SKILL.md +++ b/skills/recipe-create-events-from-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-events-from-sheet description: "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-expense-tracker/SKILL.md b/skills/recipe-create-expense-tracker/SKILL.md index 4dc60963..86edb3c7 100644 --- a/skills/recipe-create-expense-tracker/SKILL.md +++ b/skills/recipe-create-expense-tracker/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-expense-tracker description: "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-feedback-form/SKILL.md b/skills/recipe-create-feedback-form/SKILL.md index d49f1866..2be67918 100644 --- a/skills/recipe-create-feedback-form/SKILL.md +++ b/skills/recipe-create-feedback-form/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-feedback-form description: "Create a Google Form for feedback and share it via Gmail." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-gmail-filter/SKILL.md b/skills/recipe-create-gmail-filter/SKILL.md index b94c9a82..32e85606 100644 --- a/skills/recipe-create-gmail-filter/SKILL.md +++ b/skills/recipe-create-gmail-filter/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-gmail-filter description: "Create a Gmail filter to automatically label, star, or categorize incoming messages." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-meet-space/SKILL.md b/skills/recipe-create-meet-space/SKILL.md index 1078ce88..bd998538 100644 --- a/skills/recipe-create-meet-space/SKILL.md +++ b/skills/recipe-create-meet-space/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-meet-space description: "Create a Google Meet meeting space and share the join link." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-create-presentation/SKILL.md b/skills/recipe-create-presentation/SKILL.md index 759350e4..5cba5126 100644 --- a/skills/recipe-create-presentation/SKILL.md +++ b/skills/recipe-create-presentation/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-presentation description: "Create a new Google Slides presentation and add initial slides." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-shared-drive/SKILL.md b/skills/recipe-create-shared-drive/SKILL.md index 9322111c..2ebebb5f 100644 --- a/skills/recipe-create-shared-drive/SKILL.md +++ b/skills/recipe-create-shared-drive/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-shared-drive description: "Create a Google Shared Drive and add members with appropriate roles." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-task-list/SKILL.md b/skills/recipe-create-task-list/SKILL.md index 59aee4f7..d79a8a0b 100644 --- a/skills/recipe-create-task-list/SKILL.md +++ b/skills/recipe-create-task-list/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-task-list description: "Set up a new Google Tasks list with initial tasks." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-vacation-responder/SKILL.md b/skills/recipe-create-vacation-responder/SKILL.md index ab47efa9..3ad7c2e6 100644 --- a/skills/recipe-create-vacation-responder/SKILL.md +++ b/skills/recipe-create-vacation-responder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-vacation-responder description: "Enable a Gmail out-of-office auto-reply with a custom message and date range." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-draft-email-from-doc/SKILL.md b/skills/recipe-draft-email-from-doc/SKILL.md index 0141c63c..993a2cfe 100644 --- a/skills/recipe-draft-email-from-doc/SKILL.md +++ b/skills/recipe-draft-email-from-doc/SKILL.md @@ -2,7 +2,7 @@ name: recipe-draft-email-from-doc description: "Read content from a Google Doc and use it as the body of a Gmail message." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-email-drive-link/SKILL.md b/skills/recipe-email-drive-link/SKILL.md index 0f08c40f..17e190bc 100644 --- a/skills/recipe-email-drive-link/SKILL.md +++ b/skills/recipe-email-drive-link/SKILL.md @@ -2,7 +2,7 @@ name: recipe-email-drive-link description: "Share a Google Drive file and email the link with a message to recipients." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-find-free-time/SKILL.md b/skills/recipe-find-free-time/SKILL.md index 092a5244..4d3ab2f8 100644 --- a/skills/recipe-find-free-time/SKILL.md +++ b/skills/recipe-find-free-time/SKILL.md @@ -2,7 +2,7 @@ name: recipe-find-free-time description: "Query Google Calendar free/busy status for multiple users to find a meeting slot." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-find-large-files/SKILL.md b/skills/recipe-find-large-files/SKILL.md index 0bd5bd1f..27610996 100644 --- a/skills/recipe-find-large-files/SKILL.md +++ b/skills/recipe-find-large-files/SKILL.md @@ -2,7 +2,7 @@ name: recipe-find-large-files description: "Identify large Google Drive files consuming storage quota." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-forward-labeled-emails/SKILL.md b/skills/recipe-forward-labeled-emails/SKILL.md index 1ec1425f..5162a63d 100644 --- a/skills/recipe-forward-labeled-emails/SKILL.md +++ b/skills/recipe-forward-labeled-emails/SKILL.md @@ -2,7 +2,7 @@ name: recipe-forward-labeled-emails description: "Find Gmail messages with a specific label and forward them to another address." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-generate-report-from-sheet/SKILL.md b/skills/recipe-generate-report-from-sheet/SKILL.md index 505f9c16..62bd1ed5 100644 --- a/skills/recipe-generate-report-from-sheet/SKILL.md +++ b/skills/recipe-generate-report-from-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-generate-report-from-sheet description: "Read data from a Google Sheet and create a formatted Google Docs report." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-label-and-archive-emails/SKILL.md b/skills/recipe-label-and-archive-emails/SKILL.md index 79ffd11a..c23febd9 100644 --- a/skills/recipe-label-and-archive-emails/SKILL.md +++ b/skills/recipe-label-and-archive-emails/SKILL.md @@ -2,7 +2,7 @@ name: recipe-label-and-archive-emails description: "Apply Gmail labels to matching messages and archive them to keep your inbox clean." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-log-deal-update/SKILL.md b/skills/recipe-log-deal-update/SKILL.md index f52ce1d8..67f98aa0 100644 --- a/skills/recipe-log-deal-update/SKILL.md +++ b/skills/recipe-log-deal-update/SKILL.md @@ -2,7 +2,7 @@ name: recipe-log-deal-update description: "Append a deal status update to a Google Sheets sales tracking spreadsheet." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "sales" diff --git a/skills/recipe-organize-drive-folder/SKILL.md b/skills/recipe-organize-drive-folder/SKILL.md index d522b268..ac74d1db 100644 --- a/skills/recipe-organize-drive-folder/SKILL.md +++ b/skills/recipe-organize-drive-folder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-organize-drive-folder description: "Create a Google Drive folder structure and move files into the right locations." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-plan-weekly-schedule/SKILL.md b/skills/recipe-plan-weekly-schedule/SKILL.md index 0a8e519b..f8445ac1 100644 --- a/skills/recipe-plan-weekly-schedule/SKILL.md +++ b/skills/recipe-plan-weekly-schedule/SKILL.md @@ -2,7 +2,7 @@ name: recipe-plan-weekly-schedule description: "Review your Google Calendar week, identify gaps, and add events to fill them." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-post-mortem-setup/SKILL.md b/skills/recipe-post-mortem-setup/SKILL.md index 9cbd845c..88dcfc83 100644 --- a/skills/recipe-post-mortem-setup/SKILL.md +++ b/skills/recipe-post-mortem-setup/SKILL.md @@ -2,7 +2,7 @@ name: recipe-post-mortem-setup description: "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "engineering" diff --git a/skills/recipe-reschedule-meeting/SKILL.md b/skills/recipe-reschedule-meeting/SKILL.md index 9b6b727b..83be0954 100644 --- a/skills/recipe-reschedule-meeting/SKILL.md +++ b/skills/recipe-reschedule-meeting/SKILL.md @@ -2,7 +2,7 @@ name: recipe-reschedule-meeting description: "Move a Google Calendar event to a new time and automatically notify all attendees." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-review-meet-participants/SKILL.md b/skills/recipe-review-meet-participants/SKILL.md index 46297a60..ae54f630 100644 --- a/skills/recipe-review-meet-participants/SKILL.md +++ b/skills/recipe-review-meet-participants/SKILL.md @@ -2,7 +2,7 @@ name: recipe-review-meet-participants description: "Review who attended a Google Meet conference and for how long." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-review-overdue-tasks/SKILL.md b/skills/recipe-review-overdue-tasks/SKILL.md index 153ca330..396ca928 100644 --- a/skills/recipe-review-overdue-tasks/SKILL.md +++ b/skills/recipe-review-overdue-tasks/SKILL.md @@ -2,7 +2,7 @@ name: recipe-review-overdue-tasks description: "Find Google Tasks that are past due and need attention." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-save-email-attachments/SKILL.md b/skills/recipe-save-email-attachments/SKILL.md index acaaee34..2ea287ee 100644 --- a/skills/recipe-save-email-attachments/SKILL.md +++ b/skills/recipe-save-email-attachments/SKILL.md @@ -2,7 +2,7 @@ name: recipe-save-email-attachments description: "Find Gmail messages with attachments and save them to a Google Drive folder." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-save-email-to-doc/SKILL.md b/skills/recipe-save-email-to-doc/SKILL.md index 5b8ff168..5554e31d 100644 --- a/skills/recipe-save-email-to-doc/SKILL.md +++ b/skills/recipe-save-email-to-doc/SKILL.md @@ -2,7 +2,7 @@ name: recipe-save-email-to-doc description: "Save a Gmail message body into a Google Doc for archival or reference." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-schedule-recurring-event/SKILL.md b/skills/recipe-schedule-recurring-event/SKILL.md index 8897e9e8..152d3e57 100644 --- a/skills/recipe-schedule-recurring-event/SKILL.md +++ b/skills/recipe-schedule-recurring-event/SKILL.md @@ -2,7 +2,7 @@ name: recipe-schedule-recurring-event description: "Create a recurring Google Calendar event with attendees." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-send-team-announcement/SKILL.md b/skills/recipe-send-team-announcement/SKILL.md index 8392a5d7..70b4ee0b 100644 --- a/skills/recipe-send-team-announcement/SKILL.md +++ b/skills/recipe-send-team-announcement/SKILL.md @@ -2,7 +2,7 @@ name: recipe-send-team-announcement description: "Send a team announcement via both Gmail and a Google Chat space." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "communication" diff --git a/skills/recipe-share-doc-and-notify/SKILL.md b/skills/recipe-share-doc-and-notify/SKILL.md index 650c3dc8..9a5f8695 100644 --- a/skills/recipe-share-doc-and-notify/SKILL.md +++ b/skills/recipe-share-doc-and-notify/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-doc-and-notify description: "Share a Google Docs document with edit access and email collaborators the link." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-share-event-materials/SKILL.md b/skills/recipe-share-event-materials/SKILL.md index cf335588..ff730a54 100644 --- a/skills/recipe-share-event-materials/SKILL.md +++ b/skills/recipe-share-event-materials/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-event-materials description: "Share Google Drive files with all attendees of a Google Calendar event." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-share-folder-with-team/SKILL.md b/skills/recipe-share-folder-with-team/SKILL.md index 1172e018..5c550568 100644 --- a/skills/recipe-share-folder-with-team/SKILL.md +++ b/skills/recipe-share-folder-with-team/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-folder-with-team description: "Share a Google Drive folder and all its contents with a list of collaborators." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-sync-contacts-to-sheet/SKILL.md b/skills/recipe-sync-contacts-to-sheet/SKILL.md index 9b196e76..d924426b 100644 --- a/skills/recipe-sync-contacts-to-sheet/SKILL.md +++ b/skills/recipe-sync-contacts-to-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-sync-contacts-to-sheet description: "Export Google Contacts directory to a Google Sheets spreadsheet." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-watch-drive-changes/SKILL.md b/skills/recipe-watch-drive-changes/SKILL.md index d3645b75..14f3d79a 100644 --- a/skills/recipe-watch-drive-changes/SKILL.md +++ b/skills/recipe-watch-drive-changes/SKILL.md @@ -2,7 +2,7 @@ name: recipe-watch-drive-changes description: "Subscribe to change notifications on a Google Drive file or folder." metadata: - version: 0.22.3 + version: 0.22.5 openclaw: category: "recipe" domain: "engineering" From 4907fc008a14a4cb9b3e4359f00f476e6eb81965 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 6 Apr 2026 21:21:25 +0900 Subject: [PATCH 05/44] chore: remove stale TODO-upstream-rebase.md --- TODO-upstream-rebase.md | 116 ---------------------------------------- 1 file changed, 116 deletions(-) delete mode 100644 TODO-upstream-rebase.md diff --git a/TODO-upstream-rebase.md b/TODO-upstream-rebase.md deleted file mode 100644 index 0dc22c64..00000000 --- a/TODO-upstream-rebase.md +++ /dev/null @@ -1,116 +0,0 @@ -# upstream v0.22.1 ベースに MCP を載せ直す - -## 背景 - -- upstream が v0.19.0 で **cargo ワークスペース化**(`src/` → `crates/google-workspace-cli/src/`) -- upstream が `mail-builder` クレートに移行し、自前の `MessageBuilder` を廃止 -- 265 コミット・108 ファイルのコンフリクトにより通常マージは困難 -- **方針: upstream を丸ごとベースにして MCP を載せ直す** - -## 作業手順 - -### 1. upstream/main をベースにブランチ作成 - -```bash -git fetch upstream -git checkout -b rebase-mcp-on-upstream upstream/main -``` - -### 2. フォーク独自ファイルを追加 - -- [ ] `crates/google-workspace-cli/src/mcp_server.rs` — MCP サーバー本体(1,368行) - - **注意**: `execute_method()` のシグネチャが変更済み - - `upload_path: Option<&str>` → `upload: Option>` に変更 - - MCP から呼ぶ場合は `None` でOK(ファイルアップロードしないため) - - **注意**: `MessageBuilder` が `mail_builder::MessageBuilder` に変更済み - - `handle_gmail_send()` を新 API に書き直す必要あり - - 参考: `crates/google-workspace-cli/src/helpers/gmail/send.rs` の `create_send_raw_message()` - - **注意**: `build_raw_send_body()` が廃止 → `dispatch_raw_email()` に統合 - - MCP 用に `dispatch_raw_email` 相当のロジックを組み直す or 関数を `pub(crate)` にして再利用 -- [ ] `FORK.md` — 英語版フォーク説明(現行ファイルをそのままコピー) -- [ ] `FORK.ja.md` — 日本語版フォーク説明(現行ファイルをそのままコピー) -- [ ] `CLAUDE.md` — フォーク固有の注記を追記(upstream 版に追記する形) -- [ ] `.github/workflows/sync-upstream.yml` — upstream 同期ワークフロー -- [ ] `.changeset/mcp-helper-tools.md` — changeset - -### 3. 既存ファイルの修正 - -- [ ] `crates/google-workspace-cli/src/main.rs` - - `mod mcp_server;` 追加 - - `if first_arg == "mcp"` エントリポイント追加(helpers 統合部分の前に配置) -- [ ] `crates/google-workspace-cli/src/helpers/gmail/mod.rs` - - MCP から使う関数・型を `pub(super)` → `pub(crate)` に変更 - - 対象(upstream の新構造で要確認): - - `resolve_mail_method()` (旧 `resolve_send_method()`) - - `dispatch_raw_email()` (MCP から直接呼ぶなら) - - `finalize_message()` (MCP で RFC 2822 を組み立てるなら) - - `Mailbox`, `to_mb_address_list()` 等 - - `ThreadingHeaders` - -### 4. mcp_server.rs の `handle_gmail_send()` 書き直し - -upstream の `send.rs::create_send_raw_message()` を参考に: - -```rust -// 旧(自前 MessageBuilder) -let raw_message = crate::helpers::gmail::MessageBuilder { - to, subject, from: None, cc, bcc, threading: None, html: false, -}.build(body_text); -let send_body = crate::helpers::gmail::build_raw_send_body(&raw_message, None); - -// 新(mail_builder) -let mb = mail_builder::MessageBuilder::new() - .to(to_mb_address_list(&to_mailboxes)) - .subject(subject); -let mb = apply_optional_headers(mb, None, cc_mailboxes, bcc_mailboxes); -let raw = finalize_message(mb, body_text, false, &[])?; -// → dispatch_raw_email() 相当のロジックで execute_method() 呼び出し -``` - -### 5. 不要ワークフロー削除 - -upstream にある以下を削除: -- `.github/workflows/automation.yml` -- `.github/workflows/cla.yml` -- `.github/workflows/coverage.yml` -- `.github/workflows/generate-skills.yml` -- `.github/workflows/publish-skills.yml` -- `.github/workflows/release-changesets.yml` -- `.github/workflows/release.yml` -- `.github/workflows/stale.yml` - -### 6. 検証 - -```bash -cargo clippy -- -D warnings -cargo test -``` - -### 7. main にマージ - -```bash -git checkout main -git reset --hard rebase-mcp-on-upstream -# または merge -git push origin main --force-with-lease -``` - -## 参照ファイル(現行フォーク) - -作業中に参照すべき現行ファイル(`main` ブランチ): -- `src/mcp_server.rs` — MCP 実装の元ネタ -- `src/main.rs:141-143` — MCP エントリポイント -- `FORK.md`, `FORK.ja.md` — そのままコピー -- `CLAUDE.md` — フォーク固有部分を抽出して追記 -- `.github/workflows/sync-upstream.yml` — そのままコピー - -## upstream の新 API ポイント - -| 旧(v0.16.0) | 新(v0.22.1) | -|---|---| -| `src/` | `crates/google-workspace-cli/src/` | -| 自前 `MessageBuilder` struct | `mail_builder::MessageBuilder` | -| `build_raw_send_body()` | `dispatch_raw_email()` に統合 | -| `resolve_send_method()` | `resolve_mail_method(doc, draft)` | -| `upload_path: Option<&str>` + `upload_content_type: Option<&str>` | `upload: Option>` | -| `Cargo.toml`(単一 crate) | `Cargo.toml`(workspace) + `crates/*/Cargo.toml` | From 7c10f40ffa0ccb35fae08d83c06a9b05195a353f Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 6 Apr 2026 22:17:30 +0900 Subject: [PATCH 06/44] docs: add precise dates to MCP timeline in FORK.md MCP server was added on 2026-03-04 and removed just 2 days later on 2026-03-06. Updated both English and Japanese versions. --- FORK.ja.md | 7 ++++--- FORK.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/FORK.ja.md b/FORK.ja.md index e6af5892..de80b856 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -87,9 +87,10 @@ cargo install --path crates/google-workspace-cli | 時期 | 出来事 | |---|---| -| 2026-03 | `fix!: Remove MCP server mode` — upstream が breaking change として MCP サーバーを削除 | -| 2026-03 | ブランチ `fix/mcp-hyphen-tool-names` が upstream に出現 — ツール名ハイフン化で MCP 復活の兆し | -| 2026-03 | 同ブランチがマージされずに削除 — upstream での MCP 復活は見送り | +| 2026-03-04 | `feat: add gws mcp server` — upstream に MCP サーバーが追加 | +| 2026-03-05 | ブランチ `fix/mcp-hyphen-tool-names` が upstream に出現 — ツール名の区切り文字をアンダースコアからハイフンに変更 | +| 2026-03-06 | `fix!: Remove MCP server mode` — 追加からわずか2日で upstream が breaking change として MCP サーバーを削除 | +| 2026-03-06 | 同ブランチがマージされずに削除 — upstream での MCP 復活は見送り | ## upstream 同期方針 diff --git a/FORK.md b/FORK.md index 70290229..c72dbc7b 100644 --- a/FORK.md +++ b/FORK.md @@ -87,9 +87,10 @@ This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release | Date | Event | |---|---| -| 2026-03 | `fix!: Remove MCP server mode` — MCP server removed from upstream as a breaking change | -| 2026-03 | Branch `fix/mcp-hyphen-tool-names` appeared in upstream — looked like MCP might return with hyphenated tool names | -| 2026-03 | Branch `fix/mcp-hyphen-tool-names` deleted without being merged — MCP remains absent from upstream | +| 2026-03-04 | `feat: add gws mcp server` — MCP server added to upstream | +| 2026-03-05 | Branch `fix/mcp-hyphen-tool-names` appeared in upstream — tool name separator change from underscore to hyphen | +| 2026-03-06 | `fix!: Remove MCP server mode` — MCP server removed from upstream as a breaking change, just 2 days after introduction | +| 2026-03-06 | Branch `fix/mcp-hyphen-tool-names` deleted without being merged — MCP remains absent from upstream | ## Upstream sync policy From be8a4fd9e0331eb86f6c2d6de69fbd78d52df29b Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 6 Apr 2026 22:46:41 +0900 Subject: [PATCH 07/44] style: cargo fmt mcp_server.rs --- crates/google-workspace-cli/src/mcp_server.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs index 0b38a7e6..c7868b18 100644 --- a/crates/google-workspace-cli/src/mcp_server.rs +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -207,15 +207,13 @@ async fn handle_request( "tools": tools_cache.as_ref().unwrap() })) } - "tools/call" => { - match handle_tools_call(params, config).await { - Ok(val) => Ok(val), - Err(e) => Ok(json!({ - "content": [{ "type": "text", "text": e.to_string() }], - "isError": true - })), - } - } + "tools/call" => match handle_tools_call(params, config).await { + Ok(val) => Ok(val), + Err(e) => Ok(json!({ + "content": [{ "type": "text", "text": e.to_string() }], + "isError": true + })), + }, _ => Err(GwsError::Validation(format!( "Method not supported: {}", method @@ -491,15 +489,16 @@ async fn handle_gmail_send(arguments: &Value) -> Result { let raw_message = crate::helpers::gmail::finalize_message(mb, body_text, false, &[])?; // Fetch Gmail discovery doc and resolve the send method - let (api_name, version) = - crate::parse_service_and_version(&["gmail".to_string()], "gmail")?; + let (api_name, version) = crate::parse_service_and_version(&["gmail".to_string()], "gmail")?; let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; let send_method = crate::helpers::gmail::resolve_mail_method(&doc, false)?; let params = json!({ "userId": "me" }); let params_str = params.to_string(); - let scopes: Vec<&str> = crate::select_scope(&send_method.scopes).into_iter().collect(); + let scopes: Vec<&str> = crate::select_scope(&send_method.scopes) + .into_iter() + .collect(); let (token, auth_method) = match crate::auth::get_token(&scopes).await { Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), Err(e) => { From f7019fb247490d440eb3d5aa9252fcb5513c81a3 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 7 Apr 2026 13:26:00 +0900 Subject: [PATCH 08/44] =?UTF-8?q?feat:=20add=20gmail=5Freply=20MCP=20helpe?= =?UTF-8?q?r=20tool=20with=20fa=C3=A7ade=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mcp_compose_reply() façade in gmail/mod.rs (Fork-only MCP bridge block) to expose reply logic without changing upstream pub(super) visibility - Extract send_raw_gmail() shared function for Gmail send/reply/forward - Refactor handle_gmail_send() to use send_raw_gmail() - Add handle_gmail_reply() with threading headers, reply-to resolution, and reply-all support with self-dedup - Improve helper dispatch to match expression for extensibility - Add get_required_str/get_optional_str utilities - Add 12 new tests (29 total MCP tests passing) --- .../src/helpers/gmail/mod.rs | 116 +++++++ crates/google-workspace-cli/src/mcp_server.rs | 282 ++++++++++++++---- 2 files changed, 341 insertions(+), 57 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index e8768bb5..f86afe90 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -3980,3 +3980,119 @@ mod tests { ); } } + +// --------------------------------------------------------------------------- +// Fork-only: MCP bridge functions +// +// These pub(crate) façade functions expose internal helpers to the MCP server +// (src/mcp_server.rs) WITHOUT changing the visibility of upstream functions. +// When merging upstream, re-apply this block at the end of the file. +// --------------------------------------------------------------------------- + +/// Compose a reply message for the MCP `gmail_reply` helper tool. +/// +/// Fetches the original message metadata, resolves the reply recipient, +/// builds threading headers, and returns the RFC 2822 raw message together +/// with the thread ID so the caller can send it via the Gmail API. +pub(crate) async fn mcp_compose_reply( + message_id: &str, + body_text: &str, + reply_all: bool, + cc: Option<&[Mailbox]>, + bcc: Option<&[Mailbox]>, +) -> Result<(String, Option), GwsError> { + // 1. Auth + fetch original message + let token = crate::auth::get_token(&[GMAIL_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let client = crate::client::build_client()?; + let original = fetch_message_metadata(&client, &token, message_id).await?; + + // 2. Determine reply recipients + let to_mailboxes = if reply_all { + // Reply-To (or From) + original To + CC, excluding self + let mut recipients = match &original.reply_to { + Some(rt) => rt.clone(), + None => vec![original.from.clone()], + }; + recipients.extend(original.to.iter().cloned()); + if let Some(orig_cc) = &original.cc { + recipients.extend(orig_cc.iter().cloned()); + } + // Best-effort self-dedup: fetch profile and exclude own address + if let Ok(profile_email) = fetch_self_email(&client, &token).await { + let lower = profile_email.to_lowercase(); + recipients.retain(|m| m.email_lowercase() != lower); + } + recipients + } else { + match &original.reply_to { + Some(rt) => rt.clone(), + None => vec![original.from.clone()], + } + }; + + if to_mailboxes.is_empty() { + return Err(GwsError::Validation( + "No recipient could be determined from the original message".to_string(), + )); + } + + // 3. Subject + let subject = if original.subject.to_lowercase().starts_with("re:") { + original.subject.clone() + } else { + format!("Re: {}", original.subject) + }; + + // 4. Threading headers + let refs = build_references_chain(&original); + let threading = ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }; + + // 5. Build RFC 2822 message + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(&to_mailboxes)) + .subject(&subject); + let mb = apply_optional_headers(mb, None, cc, bcc); + let mb = set_threading_headers(mb, &threading); + let raw_message = finalize_message(mb, body_text, false, &[])?; + + Ok((raw_message, original.thread_id.clone())) +} + +/// Wrapper around the internal `build_send_metadata` for MCP use. +pub(crate) fn mcp_build_send_metadata(thread_id: Option<&str>, draft: bool) -> Option { + build_send_metadata(thread_id, draft) +} + +/// Fetch the authenticated user's primary email (for self-dedup in reply-all). +async fn fetch_self_email(client: &reqwest::Client, token: &str) -> Result { + let resp = crate::client::send_with_retry(|| { + client + .get("https://gmail.googleapis.com/gmail/v1/users/me/profile") + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch user profile: {e}")))?; + + if !resp.status().is_success() { + return Err(GwsError::Other(anyhow::anyhow!( + "Profile fetch failed with status {}", + resp.status() + ))); + } + + let profile: serde_json::Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse profile: {e}")))?; + + profile + .get("emailAddress") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| GwsError::Other(anyhow::anyhow!("Profile missing emailAddress"))) +} diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs index c7868b18..2b61d161 100644 --- a/crates/google-workspace-cli/src/mcp_server.rs +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -440,70 +440,78 @@ fn append_helper_tools(services: &[String], tools: &mut Vec) { "required": ["to", "subject", "body"] } })); + tools.push(json!({ + "name": "gmail_reply", + "description": "Reply to an email within its existing thread. Automatically sets threading headers (In-Reply-To, References) and Re: subject prefix. Use gmail_users_messages_list or gmail_users_messages_get to find the message_id.", + "inputSchema": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "Gmail message ID to reply to" + }, + "body": { + "type": "string", + "description": "Reply body (plain text)" + }, + "reply_all": { + "type": "boolean", + "description": "Reply to all recipients instead of just the sender (default: false)" + }, + "cc": { + "type": "string", + "description": "Additional CC email address(es), comma-separated" + }, + "bcc": { + "type": "string", + "description": "BCC email address(es), comma-separated" + } + }, + "required": ["message_id", "body"] + } + })); } } -async fn handle_gmail_send(arguments: &Value) -> Result { - let to = arguments - .get("to") - .and_then(|v| v.as_str()) - .ok_or_else(|| GwsError::Validation("Missing 'to' parameter".to_string()))?; - let subject = arguments - .get("subject") - .and_then(|v| v.as_str()) - .ok_or_else(|| GwsError::Validation("Missing 'subject' parameter".to_string()))?; - let body_text = arguments - .get("body") - .and_then(|v| v.as_str()) - .ok_or_else(|| GwsError::Validation("Missing 'body' parameter".to_string()))?; - let cc_str = arguments - .get("cc") - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()); - let bcc_str = arguments - .get("bcc") - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()); - - // Build RFC 2822 message using mail_builder (via shared helpers) - let to_mailboxes = crate::helpers::gmail::Mailbox::parse_list(to); - if to_mailboxes.is_empty() { - return Err(GwsError::Validation( - "'to' must specify at least one recipient".to_string(), - )); - } - let cc_mailboxes = cc_str.map(crate::helpers::gmail::Mailbox::parse_list); - let bcc_mailboxes = bcc_str.map(crate::helpers::gmail::Mailbox::parse_list); +// --------------------------------------------------------------------------- +// Common MCP helper utilities +// --------------------------------------------------------------------------- - let mb = mail_builder::MessageBuilder::new() - .to(crate::helpers::gmail::to_mb_address_list(&to_mailboxes)) - .subject(subject); - - let mb = crate::helpers::gmail::apply_optional_headers( - mb, - None, - cc_mailboxes.as_deref(), - bcc_mailboxes.as_deref(), - ); +/// Extract a required string parameter from MCP tool arguments. +fn get_required_str<'a>(args: &'a Value, name: &str) -> Result<&'a str, GwsError> { + args.get(name) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| GwsError::Validation(format!("Missing '{name}' parameter"))) +} - let raw_message = crate::helpers::gmail::finalize_message(mb, body_text, false, &[])?; +/// Extract an optional string parameter from MCP tool arguments. +fn get_optional_str<'a>(args: &'a Value, name: &str) -> Option<&'a str> { + args.get(name) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} - // Fetch Gmail discovery doc and resolve the send method +/// Send a raw RFC 2822 message via Gmail API. +/// +/// Shared by `handle_gmail_send` and `handle_gmail_reply`. +async fn send_raw_gmail( + raw_message: &str, + thread_id: Option<&str>, + draft: bool, +) -> Result { let (api_name, version) = crate::parse_service_and_version(&["gmail".to_string()], "gmail")?; let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?; - let send_method = crate::helpers::gmail::resolve_mail_method(&doc, false)?; + let method = crate::helpers::gmail::resolve_mail_method(&doc, draft)?; + let metadata = crate::helpers::gmail::mcp_build_send_metadata(thread_id, draft); let params = json!({ "userId": "me" }); let params_str = params.to_string(); - let scopes: Vec<&str> = crate::select_scope(&send_method.scopes) - .into_iter() - .collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let (token, auth_method) = match crate::auth::get_token(&scopes).await { Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), - Err(e) => { - return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))); - } + Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))), }; let pagination = crate::executor::PaginationConfig { @@ -514,9 +522,9 @@ async fn handle_gmail_send(arguments: &Value) -> Result { let result = crate::executor::execute_method( &doc, - send_method, + method, Some(¶ms_str), - None, + metadata.as_deref(), token.as_deref(), auth_method, None, @@ -535,7 +543,7 @@ async fn handle_gmail_send(arguments: &Value) -> Result { let text_content = match result { Some(val) => serde_json::to_string_pretty(&val).unwrap_or_else(|_| "[]".to_string()), - None => "Email sent successfully.".to_string(), + None => "Operation completed successfully.".to_string(), }; Ok(json!({ @@ -544,6 +552,67 @@ async fn handle_gmail_send(arguments: &Value) -> Result { })) } +// --------------------------------------------------------------------------- +// Gmail helper handlers +// --------------------------------------------------------------------------- + +async fn handle_gmail_send(arguments: &Value) -> Result { + let to = get_required_str(arguments, "to")?; + let subject = get_required_str(arguments, "subject")?; + let body_text = get_required_str(arguments, "body")?; + let cc_str = get_optional_str(arguments, "cc"); + let bcc_str = get_optional_str(arguments, "bcc"); + + let to_mailboxes = crate::helpers::gmail::Mailbox::parse_list(to); + if to_mailboxes.is_empty() { + return Err(GwsError::Validation( + "'to' must specify at least one recipient".to_string(), + )); + } + let cc_mailboxes = cc_str.map(crate::helpers::gmail::Mailbox::parse_list); + let bcc_mailboxes = bcc_str.map(crate::helpers::gmail::Mailbox::parse_list); + + let mb = mail_builder::MessageBuilder::new() + .to(crate::helpers::gmail::to_mb_address_list(&to_mailboxes)) + .subject(subject); + + let mb = crate::helpers::gmail::apply_optional_headers( + mb, + None, + cc_mailboxes.as_deref(), + bcc_mailboxes.as_deref(), + ); + + let raw_message = crate::helpers::gmail::finalize_message(mb, body_text, false, &[])?; + + send_raw_gmail(&raw_message, None, false).await +} + +async fn handle_gmail_reply(arguments: &Value) -> Result { + let message_id = get_required_str(arguments, "message_id")?; + let body_text = get_required_str(arguments, "body")?; + let reply_all = arguments + .get("reply_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let cc_str = get_optional_str(arguments, "cc"); + let bcc_str = get_optional_str(arguments, "bcc"); + + let cc_mailboxes = cc_str.map(crate::helpers::gmail::Mailbox::parse_list); + let bcc_mailboxes = bcc_str.map(crate::helpers::gmail::Mailbox::parse_list); + + let (raw_message, thread_id) = crate::helpers::gmail::mcp_compose_reply( + message_id, + body_text, + reply_all, + cc_mailboxes.as_deref(), + bcc_mailboxes.as_deref(), + ) + .await?; + + send_raw_gmail(&raw_message, thread_id.as_deref(), false).await +} + fn walk_resources(prefix: &str, resources: &HashMap, tools: &mut Vec) { for (res_name, res) in resources { let new_prefix = format!("{}_{}", prefix, res_name); @@ -794,13 +863,19 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result Some(handle_gmail_send(arguments).await), + "gmail_reply" => Some(handle_gmail_reply(arguments).await), + _ => None, + }; + if let Some(result) = helper_result { if !config.helpers { return Err(GwsError::Validation( "Helper tools are not enabled. Re-run with --helpers flag.".to_string(), )); } - return handle_gmail_send(arguments).await; + return result; } // Compact mode @@ -1264,12 +1339,13 @@ mod tests { } #[test] - fn test_append_helper_tools_gmail_adds_send() { + fn test_append_helper_tools_gmail_adds_send_and_reply() { let services = vec!["gmail".to_string()]; let mut tools = Vec::new(); append_helper_tools(&services, &mut tools); - assert_eq!(tools.len(), 1); + assert_eq!(tools.len(), 2); assert_eq!(tools[0]["name"], "gmail_send"); + assert_eq!(tools[1]["name"], "gmail_reply"); let schema = &tools[0]["inputSchema"]; let required = schema["required"].as_array().unwrap(); @@ -1331,4 +1407,96 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("--helpers")); } + + // --- gmail_reply tests --- + + #[test] + fn test_append_helper_tools_gmail_reply_schema() { + let services = vec!["gmail".to_string()]; + let mut tools = Vec::new(); + append_helper_tools(&services, &mut tools); + + let reply_tool = tools.iter().find(|t| t["name"] == "gmail_reply").unwrap(); + let schema = &reply_tool["inputSchema"]; + let required = schema["required"].as_array().unwrap(); + let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_strs.contains(&"message_id")); + assert!(required_strs.contains(&"body")); + + let props = schema["properties"].as_object().unwrap(); + assert!(props.contains_key("reply_all")); + assert!(props.contains_key("cc")); + assert!(props.contains_key("bcc")); + } + + #[tokio::test] + async fn test_handle_gmail_reply_missing_message_id() { + let args = json!({"body": "Thanks!"}); + let result = handle_gmail_reply(&args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("'message_id'")); + } + + #[tokio::test] + async fn test_handle_gmail_reply_missing_body() { + let args = json!({"message_id": "abc123"}); + let result = handle_gmail_reply(&args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("'body'")); + } + + #[tokio::test] + async fn test_gmail_reply_rejected_when_helpers_disabled() { + let config = ServerConfig { + services: vec!["gmail".to_string()], + workflows: false, + helpers: false, + tool_mode: ToolMode::Full, + }; + let params = json!({ + "name": "gmail_reply", + "arguments": {"message_id": "abc123", "body": "Thanks!"} + }); + let result = handle_tools_call(¶ms, &config).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("--helpers")); + } + + // --- Common utility tests --- + + #[test] + fn test_get_required_str_present() { + let args = json!({"name": "test"}); + assert_eq!(get_required_str(&args, "name").unwrap(), "test"); + } + + #[test] + fn test_get_required_str_missing() { + let args = json!({}); + assert!(get_required_str(&args, "name").is_err()); + } + + #[test] + fn test_get_required_str_empty() { + let args = json!({"name": " "}); + assert!(get_required_str(&args, "name").is_err()); + } + + #[test] + fn test_get_optional_str_present() { + let args = json!({"cc": "a@b.com"}); + assert_eq!(get_optional_str(&args, "cc"), Some("a@b.com")); + } + + #[test] + fn test_get_optional_str_missing() { + let args = json!({}); + assert_eq!(get_optional_str(&args, "cc"), None); + } + + #[test] + fn test_get_optional_str_empty() { + let args = json!({"cc": ""}); + assert_eq!(get_optional_str(&args, "cc"), None); + } } From e21a49f07e16173681721ab6e72220f62b213ef4 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 7 Apr 2026 13:34:26 +0900 Subject: [PATCH 09/44] =?UTF-8?q?docs:=20add=20fa=C3=A7ade=20architecture?= =?UTF-8?q?=20docs=20and=20gmail=5Freply=20to=20tool=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: add MCP Helper Tools section with façade pattern explanation, new helper addition procedure, and upstream merge checklist - FORK.md / FORK.ja.md: add gmail_reply to helper tools table --- AGENTS.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ FORK.ja.md | 1 + FORK.md | 3 ++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 72211226..8627a833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,55 @@ Helpers are handwritten commands prefixed with `+` that provide value the schema See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers. +## MCP Helper Tools (fork-only) + +The MCP server (`gws mcp --helpers`) exposes high-level helper tools that automate complex API interactions for AI agents. These are fork-only additions. + +### Façade architecture + +To minimize upstream merge conflicts, MCP helper tools use a **façade pattern**: + +``` +helpers/gmail/mod.rs (upstream code) + └─ Fork-only MCP bridge block (end of file) + ├─ mcp_compose_reply() ← pub(crate) façade + └─ mcp_build_send_metadata() + +mcp_server.rs (fork-only file) + ├─ send_raw_gmail() ← shared Gmail send logic + ├─ handle_gmail_send() ← uses pub(crate) helpers directly + └─ handle_gmail_reply() ← calls mcp_compose_reply() façade +``` + +**Key rules:** +- **Never change `pub(super)` to `pub(crate)` on upstream functions.** Instead, add a `pub(crate)` façade function at the end of the file inside a clearly marked `// Fork-only: MCP bridge functions` block. +- Façade functions are always appended at the **end of the file** to minimize merge conflicts. +- `mcp_server.rs` itself is fork-only (upstream deleted it), so it has no merge conflicts. + +### Adding a new MCP helper tool + +1. **If the helper needs internal functions** — add a `pub(crate)` façade in the relevant `helpers/*.rs` file (end of file, in the MCP bridge block) +2. **Register the tool** in `append_helper_tools()` in `mcp_server.rs` (service-gated) +3. **Implement the handler** as `handle_(arguments)` in `mcp_server.rs` +4. **Add routing** in the `match` block inside `handle_tools_call()` +5. **Add tests** — parameter validation, schema, and `--helpers` flag gating +6. **Update FORK.md** tool table + +### Current MCP helper tools + +| Tool | Handler | Façade | Description | +|---|---|---|---| +| `gmail_send` | `handle_gmail_send` | None (uses `pub(crate)` helpers directly) | Send a new email | +| `gmail_reply` | `handle_gmail_reply` | `mcp_compose_reply` | Reply within a thread | + +### Upstream merge checklist for MCP + +After merging upstream, verify: +1. `mcp_server.rs` still compiles (check `executor::execute_method` signature changes) +2. `pub(crate)` on `Mailbox`, `to_mb_address_list`, `apply_optional_headers`, `finalize_message`, `resolve_mail_method` has not been reverted to `pub(super)` +3. MCP bridge façade block at end of `helpers/gmail/mod.rs` is still present +4. Run `cargo clippy -- -D warnings && cargo test -p google-workspace-cli -- mcp_server` + ## Environment Variables ### Authentication diff --git a/FORK.ja.md b/FORK.ja.md index de80b856..14b5d063 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -36,6 +36,7 @@ gws mcp -s gmail --tool-mode compact | ツール名 | 説明 | |---|---| | `gmail_send` | メール送信。to/subject/body を渡すだけで RFC 2822 フォーマット・base64url エンコードを自動処理 | +| `gmail_reply` | スレッド内返信。message_id/body を渡すだけで In-Reply-To, References, Re: 件名, threadId を自動設定 | ## インストール diff --git a/FORK.md b/FORK.md index c72dbc7b..d55f9d19 100644 --- a/FORK.md +++ b/FORK.md @@ -36,6 +36,7 @@ Enabled with the `--helpers` flag. These provide high-level operations on top of | Tool | Description | |---|---| | `gmail_send` | Send email. Just pass to/subject/body — RFC 2822 formatting and base64url encoding are handled automatically | +| `gmail_reply` | Reply within a thread. Pass message_id/body — In-Reply-To, References, Re: subject, and threadId are set automatically | ## Installation @@ -96,5 +97,5 @@ This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release - Weekly auto-merge from upstream/main via GitHub Actions (every Monday) - Conflicts trigger a PR for manual resolution -- MCP-related code (`src/mcp_server.rs`, `pub(crate)` visibility) is preserved as top priority +- MCP-related code (`src/mcp_server.rs`, `pub(crate)` visibility, MCP bridge façade functions) is preserved as top priority - Issue/PR number references (`#123`) are stripped from upstream commit messages to prevent cross-references From 1eb0ad2855513eb8e3b71d61c48848f0741685d2 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 7 Apr 2026 13:38:57 +0900 Subject: [PATCH 10/44] feat: embed git describe in --version output for fork identification Add build.rs to capture git describe at compile time. Version output now shows: gws 0.22.5 (fork/v0.22.5-mcp.1) This helps identify which fork revision is installed across machines. build.rs is fork-only and does not exist in upstream. --- crates/google-workspace-cli/build.rs | 21 +++++++++++++++++++++ crates/google-workspace-cli/src/main.rs | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 crates/google-workspace-cli/build.rs diff --git a/crates/google-workspace-cli/build.rs b/crates/google-workspace-cli/build.rs new file mode 100644 index 00000000..287a5dc3 --- /dev/null +++ b/crates/google-workspace-cli/build.rs @@ -0,0 +1,21 @@ +// Fork-only: embed git describe output for fork version identification. +// This file does not exist in upstream and will not cause merge conflicts. + +use std::process::Command; + +fn main() { + // Re-run if git HEAD changes (new commit or checkout) + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/refs/"); + + let describe = Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=GWS_FORK_DESCRIBE={describe}"); +} diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 16ca9cf8..64d780d8 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -107,7 +107,7 @@ async fn run() -> Result<(), GwsError> { } if is_version_flag(&first_arg) { - println!("gws {}", env!("CARGO_PKG_VERSION")); + println!("gws {} ({})", env!("CARGO_PKG_VERSION"), env!("GWS_FORK_DESCRIBE")); println!("This is not an officially supported Google product."); return Ok(()); } From 82075d326e21ad86c71c4ed79b08dc0e285dd477 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 13 Apr 2026 21:31:25 +0900 Subject: [PATCH 11/44] ci(sync): auto-resolve modify/delete conflicts for fork-deleted files When upstream modifies a workflow file that the fork has deleted (e.g. release.yml), the sync workflow now auto-removes it instead of creating an unresolvable PR. Remaining real conflicts still trigger a manual-resolution PR. Files auto-resolved: release.yml, automation.yml, cla.yml, coverage.yml, generate-skills.yml, publish-skills.yml, release-changesets.yml, stale.yml, dist-workspace.toml. --- .github/workflows/sync-upstream.yml | 50 ++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index bd03919e..892bd5d2 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -41,19 +41,59 @@ jobs: BRANCH="sync-upstream/$(date +%Y%m%d)" git checkout -b "$BRANCH" + # Files the fork has deleted and wants to keep deleted. + # If upstream modifies any of these, auto-resolve by removing them. + FORK_DELETED_FILES=( + ".github/workflows/release.yml" + ".github/workflows/automation.yml" + ".github/workflows/cla.yml" + ".github/workflows/coverage.yml" + ".github/workflows/generate-skills.yml" + ".github/workflows/publish-skills.yml" + ".github/workflows/release-changesets.yml" + ".github/workflows/stale.yml" + "dist-workspace.toml" + ) + if git merge upstream/main --no-edit; then + CONFLICT=false + else + # Auto-resolve modify/delete conflicts for files the fork deleted. + RESOLVED=false + for f in "${FORK_DELETED_FILES[@]}"; do + if git status --porcelain "$f" 2>/dev/null | grep -q "^DU\|^UD"; then + git rm -f "$f" || true + RESOLVED=true + fi + done + + # Check if any conflicts remain after auto-resolution. + if git diff --name-only --diff-filter=U | grep -q .; then + # Real conflicts remain — commit as-is for manual resolution. + git add -A + git commit -m "sync: merge upstream/main (conflicts need manual resolution)" --no-verify || true + CONFLICT=true + elif [ "$RESOLVED" = "true" ]; then + # Only fork-deleted-file conflicts existed, and we resolved them. + git commit --no-edit + CONFLICT=false + else + # Should not happen, but safe fallback. + git add -A + git commit -m "sync: merge upstream/main" --no-verify || true + CONFLICT=true + fi + fi + + if [ "$CONFLICT" = "false" ]; then # Strip issue/PR number references to prevent cross-references MSG=$(git log -1 --format=%B) CLEANED=$(echo "$MSG" | sed 's/ (#[0-9]\+)//g; s/#[0-9]\+//g') if [ "$MSG" != "$CLEANED" ]; then git commit --amend -m "$CLEANED" fi - echo "conflict=false" >> "$GITHUB_OUTPUT" - else - git add -A - git commit -m "sync: merge upstream/main (conflicts need manual resolution)" --no-verify || true - echo "conflict=true" >> "$GITHUB_OUTPUT" fi + echo "conflict=$CONFLICT" >> "$GITHUB_OUTPUT" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" git push -u origin "$BRANCH" From d56527782a1819011feb43da665d4df2075a6bb5 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 13 Apr 2026 22:00:40 +0900 Subject: [PATCH 12/44] docs: update repo name from gws-cli to gws-mcp in FORK docs GitHub repo renamed to shigechika/gws-mcp to reflect its actual purpose (MCP-only use). Binary name and OS keyring service name unchanged. --- FORK.ja.md | 4 ++-- FORK.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/FORK.ja.md b/FORK.ja.md index 14b5d063..9783203c 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -44,13 +44,13 @@ upstream の npm パッケージには MCP 機能が含まれていないため ```bash # GitHub から直接インストール(推奨) -cargo install --git https://github.com/shigechika/gws-cli --locked +cargo install --git https://github.com/shigechika/gws-mcp --locked ``` ローカルに clone 済みの場合は、ワーキングツリーからインストール: ```bash -cd gws-cli +cd gws-mcp cargo install --path crates/google-workspace-cli ``` diff --git a/FORK.md b/FORK.md index d55f9d19..e369401b 100644 --- a/FORK.md +++ b/FORK.md @@ -44,13 +44,13 @@ The upstream npm package does not include MCP support. Build from source: ```bash # Install directly from GitHub (recommended) -cargo install --git https://github.com/shigechika/gws-cli --locked +cargo install --git https://github.com/shigechika/gws-mcp --locked ``` If you cloned the repository locally, install from the working tree: ```bash -cd gws-cli +cd gws-mcp cargo install --path crates/google-workspace-cli ``` From 733232c79fe4c91cd78782250a27b7555168ae7f Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Mon, 13 Apr 2026 22:13:06 +0900 Subject: [PATCH 13/44] style: cargo fmt main.rs --- crates/google-workspace-cli/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 64d780d8..57508a23 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -107,7 +107,11 @@ async fn run() -> Result<(), GwsError> { } if is_version_flag(&first_arg) { - println!("gws {} ({})", env!("CARGO_PKG_VERSION"), env!("GWS_FORK_DESCRIBE")); + println!( + "gws {} ({})", + env!("CARGO_PKG_VERSION"), + env!("GWS_FORK_DESCRIBE") + ); println!("This is not an officially supported Google product."); return Ok(()); } From 51e363d49f8fce0c14177f037a1648a2757c3ba4 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 14 Apr 2026 23:03:27 +0900 Subject: [PATCH 14/44] feat(mcp): port fixes from upstream MCP issues #162, #170, #260 Address bug reports and feature requests that targeted upstream's MCP server and were auto-closed when MCP was removed: - #162: use configured service alias (not Discovery doc name) as the tool-name prefix in walk_resources, so tools/list and tools/call share one namespace for aliased services like `events`. - #170: replace split('_') tool-name parsing with resolve_tool_path, a greedy Discovery-tree resolver that handles resource names containing underscores (e.g. admin_role_assignments_list) and arbitrarily nested resources. - #260: attach readOnlyHint/destructiveHint/idempotentHint annotations derived from HTTP method to every generated tool and to the gmail_send / gmail_reply helper tools. Add unit tests for http_method_annotations and resolve_tool_path covering simple, multi-word, nested, and not-found cases. Document the ported issues (plus previously-addressed #212 and #251) in FORK.md and FORK.ja.md. --- FORK.ja.md | 12 + FORK.md | 12 + .../google-workspace-cli/src/auth_commands.rs | 3 + crates/google-workspace-cli/src/mcp_server.rs | 213 +++++++++++++++--- 4 files changed, 208 insertions(+), 32 deletions(-) diff --git a/FORK.ja.md b/FORK.ja.md index 9783203c..ef61f02e 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -84,6 +84,18 @@ cargo install --path crates/google-workspace-cli } ``` +## このフォークで対応した upstream の MCP issue + +upstream の MCP サーバーに対するバグ報告・機能要望(MCP 削除に伴い close されたもの)を、このフォークで移植・対応しています。 + +| upstream issue | 状態 | 内容 | +|---|---|---| +| [#162](https://github.com/googleworkspace/cli/issues/162) — `tools/list` が呼び出せないツール名を返す(alias と doc.name の不一致) | 対応済 | `walk_resources` がツール名プレフィックスに Discovery doc 名ではなく設定された alias を使うよう変更。`tools/list` と `tools/call` の名前空間を統一 | +| [#170](https://github.com/googleworkspace/cli/issues/170) — 複数単語のリソース名(`admin_role_assignments_list` 等)でパースが壊れる | 対応済 | `split('_')` を Discovery ツリーに対する貪欲リゾルバ(`resolve_tool_path`)に置換。アンダースコアを含むリソース名・任意の入れ子に対応 | +| [#212](https://github.com/googleworkspace/cli/issues/212) — Full mode の schema が GET メソッドにも `body`/`upload` を含む | 対応済 | `method.request.is_some()` の時のみ `body` を、`supports_media_upload == true` の時のみ `upload` を付与 | +| [#251](https://github.com/googleworkspace/cli/issues/251) — `--upload` が絶対パス・トラバーサルパスを受理する | 対応済 | MCP の `upload` 引数で絶対パス・`..` 要素を拒否 | +| [#260](https://github.com/googleworkspace/cli/issues/260) — tool annotations(`readOnlyHint` / `destructiveHint` / `idempotentHint`) | 部分対応 | HTTP method から導出した annotations を全ツールに付与。`tool_search` メタツールとページネーションは未移植 | + ## upstream MCP 定点観測 | 時期 | 出来事 | diff --git a/FORK.md b/FORK.md index e369401b..43db457a 100644 --- a/FORK.md +++ b/FORK.md @@ -84,6 +84,18 @@ This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release } ``` +## Upstream MCP issues addressed in this fork + +Bug reports and feature requests that targeted upstream's MCP server (closed when MCP was removed). This fork ports the fixes so they remain useful: + +| Upstream issue | Status | Notes | +|---|---|---| +| [#162](https://github.com/googleworkspace/cli/issues/162) — `tools/list` returns uncallable tool names for aliased services | Fixed | `walk_resources` now uses the configured service alias as tool-name prefix (instead of Discovery doc name), so `tools/list` and `tools/call` share one namespace | +| [#170](https://github.com/googleworkspace/cli/issues/170) — Tool name parsing breaks on multi-word resources (`admin_role_assignments_list` etc.) | Fixed | Replaced `split('_')` with a greedy Discovery-tree resolver (`resolve_tool_path`). Handles arbitrarily nested resources whose names contain underscores | +| [#212](https://github.com/googleworkspace/cli/issues/212) — Full-mode schemas expose `body`/`upload` on GET-only methods | Fixed | `body` is added only when `method.request.is_some()`; `upload` only when `supports_media_upload` is true | +| [#251](https://github.com/googleworkspace/cli/issues/251) — Dynamic `--upload` accepts unsafe absolute/traversal paths | Fixed | MCP `upload` argument rejects absolute paths and `..` components | +| [#260](https://github.com/googleworkspace/cli/issues/260) — Tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) | Partial | Annotations derived from HTTP method are now attached to every tool. `tool_search` meta-tool and pagination from the original proposal are not yet ported | + ## Upstream MCP timeline | Date | Event | diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..087195fe 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -1833,6 +1833,9 @@ mod tests { #[test] #[serial_test::serial] fn config_dir_returns_gws_subdir() { + // Other tests (e.g. in auth.rs) may set GOOGLE_WORKSPACE_CLI_CONFIG_DIR + // to a tempdir path that does not end in "gws", so clear it first. + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); let path = config_dir(); assert!(path.ends_with("gws")); } diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs index 2b61d161..911c9b5f 100644 --- a/crates/google-workspace-cli/src/mcp_server.rs +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -232,7 +232,7 @@ async fn build_tools_list(config: &ServerConfig) -> Result, GwsError> let (api_name, version) = crate::parse_service_and_version(&[svc_name.to_string()], svc_name)?; if let Ok(doc) = crate::discovery::fetch_discovery_document(&api_name, &version).await { - walk_resources(&doc.name, &doc.resources, &mut tools); + walk_resources(svc_name, &doc.resources, &mut tools); } else { eprintln!("[gws mcp] Warning: Failed to load discovery document for service '{}'. It will not be available as a tool.", svc_name); } @@ -438,6 +438,11 @@ fn append_helper_tools(services: &[String], tools: &mut Vec) { } }, "required": ["to", "subject", "body"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, } })); tools.push(json!({ @@ -468,6 +473,11 @@ fn append_helper_tools(services: &[String], tools: &mut Vec) { } }, "required": ["message_id", "body"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, } })); } @@ -667,7 +677,8 @@ fn walk_resources(prefix: &str, resources: &HashMap, tools tools.push(json!({ "name": tool_name, "description": description, - "inputSchema": input_schema + "inputSchema": input_schema, + "annotations": http_method_annotations(&method.http_method), })); } @@ -677,6 +688,40 @@ fn walk_resources(prefix: &str, resources: &HashMap, tools } } +/// MCP tool annotations derived from HTTP method (upstream #260). +fn http_method_annotations(http_method: &str) -> Value { + let m = http_method.to_ascii_uppercase(); + json!({ + "readOnlyHint": m == "GET", + "destructiveHint": m == "DELETE", + "idempotentHint": matches!(m.as_str(), "GET" | "PUT" | "DELETE" | "HEAD"), + }) +} + +/// Greedy resolver for underscore-joined tool names (upstream #170). +/// +/// Resource names may themselves contain underscores (e.g. `role_assignments`), +/// so `split('_')` is ambiguous. This walks the Discovery tree and consumes +/// `resource_name_` prefixes greedily, supporting arbitrarily nested resources. +fn resolve_tool_path( + remaining: &str, + resources: &HashMap, +) -> Option<(Vec, String)> { + for (res_name, res) in resources { + let prefix = format!("{}_", res_name); + if let Some(after) = remaining.strip_prefix(&prefix) { + if res.methods.contains_key(after) { + return Some((vec![res_name.clone()], after.to_string())); + } + if let Some((mut sub_path, method)) = resolve_tool_path(after, &res.resources) { + sub_path.insert(0, res_name.clone()); + return Some((sub_path, method)); + } + } + } + None +} + async fn handle_discover(arguments: &Value, config: &ServerConfig) -> Result { let service = arguments .get("service") @@ -918,16 +963,11 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result = tool_name.split('_').collect(); - if parts.len() < 3 { - return Err(GwsError::Validation(format!( - "Invalid API tool name: {}", - tool_name - ))); - } - - let svc_alias = parts[0]; + // Full mode — greedy parse that handles resource names containing underscores + // (upstream #170, e.g. `admin_role_assignments_list`). + let (svc_alias, remaining) = tool_name + .split_once('_') + .ok_or_else(|| GwsError::Validation(format!("Invalid API tool name: {}", tool_name)))?; if !config.services.contains(&svc_alias.to_string()) { return Err(GwsError::Validation(format!( @@ -940,29 +980,26 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result) -> ServerConfig { ServerConfig { services: services.into_iter().map(String::from).collect(), From 2b207a6516bbd4ec95fb01a2e6d9dace30000b65 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Wed, 15 Apr 2026 05:59:25 +0900 Subject: [PATCH 15/44] fix(gmail): case-insensitive header matching (upstream #642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_message_headers used exact-case string matching, so headers like "CC" (common from Exchange/Outlook) or "from" (lowercase from some MTAs) would fall through and be silently dropped. Per RFC 5322 §1.2.2 header field names are case-insensitive, and the sibling get_part_header already uses eq_ignore_ascii_case. Normalize the header name to lowercase before matching. Add a regression test covering mixed-case FROM/to/CC/subject/MESSAGE-ID/ references. Document in FORK.md. --- FORK.ja.md | 1 + FORK.md | 1 + .../src/helpers/gmail/mod.rs | 57 ++++++++++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/FORK.ja.md b/FORK.ja.md index ef61f02e..b7e0ad07 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -95,6 +95,7 @@ upstream の MCP サーバーに対するバグ報告・機能要望(MCP 削 | [#212](https://github.com/googleworkspace/cli/issues/212) — Full mode の schema が GET メソッドにも `body`/`upload` を含む | 対応済 | `method.request.is_some()` の時のみ `body` を、`supports_media_upload == true` の時のみ `upload` を付与 | | [#251](https://github.com/googleworkspace/cli/issues/251) — `--upload` が絶対パス・トラバーサルパスを受理する | 対応済 | MCP の `upload` 引数で絶対パス・`..` 要素を拒否 | | [#260](https://github.com/googleworkspace/cli/issues/260) — tool annotations(`readOnlyHint` / `destructiveHint` / `idempotentHint`) | 部分対応 | HTTP method から導出した annotations を全ツールに付与。`tool_search` メタツールとページネーションは未移植 | +| [#642](https://github.com/googleworkspace/cli/issues/642) — `parse_message_headers` の case-sensitive マッチが `CC` 等の非正規ケースのヘッダを落とす | 対応済 | ヘッダ名を小文字化してからマッチするよう変更。Exchange/Outlook 由来の `"CC"` 等、RFC 5322 §1.2.2 に沿った任意ケーシングを認識 | ## upstream MCP 定点観測 diff --git a/FORK.md b/FORK.md index 43db457a..63aac132 100644 --- a/FORK.md +++ b/FORK.md @@ -95,6 +95,7 @@ Bug reports and feature requests that targeted upstream's MCP server (closed whe | [#212](https://github.com/googleworkspace/cli/issues/212) — Full-mode schemas expose `body`/`upload` on GET-only methods | Fixed | `body` is added only when `method.request.is_some()`; `upload` only when `supports_media_upload` is true | | [#251](https://github.com/googleworkspace/cli/issues/251) — Dynamic `--upload` accepts unsafe absolute/traversal paths | Fixed | MCP `upload` argument rejects absolute paths and `..` components | | [#260](https://github.com/googleworkspace/cli/issues/260) — Tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) | Partial | Annotations derived from HTTP method are now attached to every tool. `tool_search` meta-tool and pagination from the original proposal are not yet ported | +| [#642](https://github.com/googleworkspace/cli/issues/642) — `parse_message_headers` case-sensitive match drops CC/headers with non-canonical casing | Fixed | Normalized header names to lowercase before matching, so `"CC"` from Exchange/Outlook, `"from"` lowercase, etc. are all recognized per RFC 5322 §1.2.2 | ## Upstream MCP timeline diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index f86afe90..3a119920 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -258,15 +258,19 @@ fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders { let name = header.get("name").and_then(|v| v.as_str()).unwrap_or(""); let value = header.get("value").and_then(|v| v.as_str()).unwrap_or(""); - match name { - "From" => parsed.from = value.to_string(), - "Reply-To" => append_address_list_header_value(&mut parsed.reply_to, value), - "To" => append_address_list_header_value(&mut parsed.to, value), - "Cc" => append_address_list_header_value(&mut parsed.cc, value), - "Subject" => parsed.subject = value.to_string(), - "Date" => parsed.date = value.to_string(), - "Message-ID" | "Message-Id" => parsed.message_id = value.to_string(), - "References" => append_header_value(&mut parsed.references, value), + // RFC 5322 §1.2.2: header field names are case-insensitive. Gmail + // preserves the sender's original casing, so `"CC"` from + // Exchange/Outlook would fall through a case-sensitive match and + // silently drop CC recipients in +reply-all (upstream #642). + match name.to_ascii_lowercase().as_str() { + "from" => parsed.from = value.to_string(), + "reply-to" => append_address_list_header_value(&mut parsed.reply_to, value), + "to" => append_address_list_header_value(&mut parsed.to, value), + "cc" => append_address_list_header_value(&mut parsed.cc, value), + "subject" => parsed.subject = value.to_string(), + "date" => parsed.date = value.to_string(), + "message-id" => parsed.message_id = value.to_string(), + "references" => append_header_value(&mut parsed.references, value), _ => {} } } @@ -2427,6 +2431,41 @@ mod tests { assert_eq!(original.body_html.as_deref(), Some("

HTML only

")); } + #[test] + fn test_parse_original_message_handles_non_canonical_header_casing() { + // Regression for upstream #642: Exchange/Outlook emit "CC" in uppercase, + // other MTAs use lowercase. All variants must be recognized. + let msg = json!({ + "threadId": "thread-1", + "snippet": "s", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "FROM", "value": "alice@example.com" }, + { "name": "to", "value": "bob@example.com" }, + { "name": "CC", "value": "carol@example.com" }, + { "name": "subject", "value": "Re: test" }, + { "name": "MESSAGE-ID", "value": "" }, + { "name": "references", "value": "" } + ], + "body": { "data": URL_SAFE.encode("body") } + } + }); + + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.from.email, "alice@example.com"); + assert_eq!(original.to.len(), 1); + assert_eq!(original.to[0].email, "bob@example.com"); + let cc = original + .cc + .expect("CC header should be recognized regardless of casing"); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "carol@example.com"); + assert_eq!(original.subject, "Re: test"); + assert_eq!(original.message_id, "msg@example.com"); + assert_eq!(original.references, vec!["ref@example.com"]); + } + #[test] fn test_parse_original_message_multipart_alternative() { let msg = json!({ From 9406dee1c8b611b4693456921dd6717b0ce16cd5 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 18 Apr 2026 21:31:17 +0900 Subject: [PATCH 16/44] chore(deps): bump rustls-webpki to 0.103.12 for RUSTSEC-2026-0098/0099 Name-constraint handling for URI names and wildcard certificates was incorrectly permissive in 0.103.10 (advisory 2026-04-14). Patch-level bump, no API changes. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7e7baec..db3c8231 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2260,9 +2260,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", From 1b8588ff343a273ba9c4cd56e3d6ab76e9c87b35 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 18 Apr 2026 21:42:54 +0900 Subject: [PATCH 17/44] docs(fork): mark upstream #573, #625, #717 as addressed (#1) These upstream issues are already resolved in this fork's code but weren't listed in the "issues addressed" table: - #573 metadataHeaders repeated expansion: Discovery parser preserves `repeated: true` and executor expands arrays into repeated query parameters. Affects Discovery-driven MCP tools too. - #625 script service registry: `ServiceEntry` for `script/v1` is registered so `gws script ...` and MCP `script_*` tools resolve. - #717 auth status stdout cleanliness: keyring backend diagnostic goes to stderr via `eprintln!`, so jq pipelines work. Docs-only change; no code touched. Listing them here makes the fork's coverage visible to visitors and lets the cross-reference from this commit land in upstream issue timelines. --- FORK.ja.md | 3 +++ FORK.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/FORK.ja.md b/FORK.ja.md index b7e0ad07..da32b819 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -96,6 +96,9 @@ upstream の MCP サーバーに対するバグ報告・機能要望(MCP 削 | [#251](https://github.com/googleworkspace/cli/issues/251) — `--upload` が絶対パス・トラバーサルパスを受理する | 対応済 | MCP の `upload` 引数で絶対パス・`..` 要素を拒否 | | [#260](https://github.com/googleworkspace/cli/issues/260) — tool annotations(`readOnlyHint` / `destructiveHint` / `idempotentHint`) | 部分対応 | HTTP method から導出した annotations を全ツールに付与。`tool_search` メタツールとページネーションは未移植 | | [#642](https://github.com/googleworkspace/cli/issues/642) — `parse_message_headers` の case-sensitive マッチが `CC` 等の非正規ケースのヘッダを落とす | 対応済 | ヘッダ名を小文字化してからマッチするよう変更。Exchange/Outlook 由来の `"CC"` 等、RFC 5322 §1.2.2 に沿った任意ケーシングを認識 | +| [#573](https://github.com/googleworkspace/cli/issues/573) — `gmail.users.messages.get` で `metadataHeaders` 配列がクエリパラメータに展開されない | 対応済 | Discovery パーサが `repeated: true` を保持(`discovery.rs`)し、JSON 配列値を複数クエリに展開する実装が入っている(`executor.rs`)。Discovery 駆動の MCP ツールも同じ挙動を継承 | +| [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service が `services.rs` に未登録で helper が到達不能 | 対応済 | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` として登録済み。`gws script ...` と MCP `script_*` ツールが正常に解決する | +| [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` が非 JSON を stdout に出力し `jq` パイプラインを破壊 | 対応済 | `Using keyring backend: ` は `credential_store.rs` で `eprintln!`(stderr)に出力される。`gws auth status \| jq .` は正常に動作 | ## upstream MCP 定点観測 diff --git a/FORK.md b/FORK.md index 63aac132..6c566030 100644 --- a/FORK.md +++ b/FORK.md @@ -96,6 +96,9 @@ Bug reports and feature requests that targeted upstream's MCP server (closed whe | [#251](https://github.com/googleworkspace/cli/issues/251) — Dynamic `--upload` accepts unsafe absolute/traversal paths | Fixed | MCP `upload` argument rejects absolute paths and `..` components | | [#260](https://github.com/googleworkspace/cli/issues/260) — Tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) | Partial | Annotations derived from HTTP method are now attached to every tool. `tool_search` meta-tool and pagination from the original proposal are not yet ported | | [#642](https://github.com/googleworkspace/cli/issues/642) — `parse_message_headers` case-sensitive match drops CC/headers with non-canonical casing | Fixed | Normalized header names to lowercase before matching, so `"CC"` from Exchange/Outlook, `"from"` lowercase, etc. are all recognized per RFC 5322 §1.2.2 | +| [#573](https://github.com/googleworkspace/cli/issues/573) — `metadataHeaders` array not expanded as repeated query params in `gmail.users.messages.get` | Fixed | Discovery parser preserves `repeated: true` (`discovery.rs`), and the executor expands JSON array values into multiple query entries (`executor.rs`). Discovery-driven MCP tools inherit the same behavior | +| [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service not registered in `services.rs` (helper unreachable) | Fixed | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` is registered, so `gws script ...` and MCP `script_*` tools resolve correctly | +| [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` prints non-JSON to stdout, breaking `jq` pipelines | Fixed | `Using keyring backend: ` is emitted via `eprintln!` to stderr (`credential_store.rs`), so `gws auth status \| jq .` parses cleanly | ## Upstream MCP timeline From 2f8f60b2db4f0bf458cb3e86aa9e244b05b46306 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 18 Apr 2026 22:30:33 +0900 Subject: [PATCH 18/44] chore(clippy): collapse nested if in script.rs match arm (#3) * chore(clippy): collapse nested if in script.rs match arm Satisfies `clippy::collapsible_match` which is enforced via `cargo clippy --workspace -- -D warnings` in the Lint job. No behavior change: falling through the `"json"` guard lands on the existing `_ => return Ok(None)` arm, matching the previous else branch. * docs(script): note the fallthrough in the appsscript match guard Add a one-line comment clarifying that other .json files intentionally fall through to the catch-all arm. Makes the match guard easier to read for anyone less familiar with Rust fallthrough semantics. --- .changeset/clippy-script-collapsible-match.md | 5 +++++ crates/google-workspace-cli/src/helpers/script.rs | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 .changeset/clippy-script-collapsible-match.md diff --git a/.changeset/clippy-script-collapsible-match.md b/.changeset/clippy-script-collapsible-match.md new file mode 100644 index 00000000..3ae4c3f1 --- /dev/null +++ b/.changeset/clippy-script-collapsible-match.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +chore(clippy): collapse nested `if` inside the `"json"` arm of the Apps Script file classifier into a match guard. No behavior change — only satisfies `clippy::collapsible_match` which is now enforced in CI. diff --git a/crates/google-workspace-cli/src/helpers/script.rs b/crates/google-workspace-cli/src/helpers/script.rs index 11bcdebe..c97e22a7 100644 --- a/crates/google-workspace-cli/src/helpers/script.rs +++ b/crates/google-workspace-cli/src/helpers/script.rs @@ -169,13 +169,8 @@ fn process_file(path: &Path) -> Result, GwsError> { filename.trim_end_matches(".js").trim_end_matches(".gs"), ), "html" => ("HTML", filename.trim_end_matches(".html")), - "json" => { - if filename == "appsscript.json" { - ("JSON", "appsscript") - } else { - return Ok(None); - } - } + // Only `appsscript.json` is uploaded as JSON; other .json files fall through to `_` below. + "json" if filename == "appsscript.json" => ("JSON", "appsscript"), _ => return Ok(None), }; From a5fd7e3116bb56f5e3e8f60be95046478f3e68a2 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 18 Apr 2026 22:47:17 +0900 Subject: [PATCH 19/44] fix(auth): stop auto-injecting cloud-platform scope in TUI (upstream #562) (#2) * fix(auth): stop auto-injecting cloud-platform scope in TUI (upstream #562) The discovery-based scope picker unconditionally appended the cloud-platform scope after the user's selection. Some Workspace organizations block cloud-platform via admin policy, so the injection caused `admin_policy_enforced` login failures for users who picked narrower, permitted scopes. Remove the auto-inject. Users who need cloud-platform (e.g. for the modelarmor helper) can either tick it in the picker or pass `--full` / `--scopes https://www.googleapis.com/auth/cloud-platform`. Also drop the now-unused `PLATFORM_SCOPE` constant from setup.rs and document the fix in the FORK.md / FORK.ja.md issue-coverage table. * chore: add changeset for upstream #562 auth TUI fix Required by the Policy Check workflow whenever .rs or Cargo.* files change. * test(auth): invariant tests for scope preset cloud-platform handling Guards against regression of upstream #562 at the scope-list layer: - `default_scopes_does_not_include_cloud_platform` ensures the default login path never silently requests cloud-platform - `full_scopes_includes_cloud_platform` confirms the opt-in path is preserved so modelarmor users have a stable entry point Also note the behavior change for modelarmor users in the changeset. --- .../auth-tui-no-cloud-platform-inject.md | 7 ++++ FORK.ja.md | 1 + FORK.md | 1 + .../google-workspace-cli/src/auth_commands.rs | 40 ++++++++++++++++--- crates/google-workspace-cli/src/setup.rs | 2 - 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .changeset/auth-tui-no-cloud-platform-inject.md diff --git a/.changeset/auth-tui-no-cloud-platform-inject.md b/.changeset/auth-tui-no-cloud-platform-inject.md new file mode 100644 index 00000000..2507f50f --- /dev/null +++ b/.changeset/auth-tui-no-cloud-platform-inject.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(auth): stop auto-injecting cloud-platform scope after TUI scope picker selection. This scope is restricted by Google and blocked by some Workspace admin policies, which caused `admin_policy_enforced` login failures for users who picked narrower, permitted scopes (upstream #562). Users who need cloud-platform (e.g. for the modelarmor helper) can tick it in the picker or pass `--full` / `--scopes https://www.googleapis.com/auth/cloud-platform`. + +Behavior change: existing users who relied on the silent cloud-platform injection to run the modelarmor helper must re-authenticate with one of the explicit paths above on their next `gws auth login`. diff --git a/FORK.ja.md b/FORK.ja.md index da32b819..5eb42232 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -99,6 +99,7 @@ upstream の MCP サーバーに対するバグ報告・機能要望(MCP 削 | [#573](https://github.com/googleworkspace/cli/issues/573) — `gmail.users.messages.get` で `metadataHeaders` 配列がクエリパラメータに展開されない | 対応済 | Discovery パーサが `repeated: true` を保持(`discovery.rs`)し、JSON 配列値を複数クエリに展開する実装が入っている(`executor.rs`)。Discovery 駆動の MCP ツールも同じ挙動を継承 | | [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service が `services.rs` に未登録で helper が到達不能 | 対応済 | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` として登録済み。`gws script ...` と MCP `script_*` ツールが正常に解決する | | [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` が非 JSON を stdout に出力し `jq` パイプラインを破壊 | 対応済 | `Using keyring backend: ` は `credential_store.rs` で `eprintln!`(stderr)に出力される。`gws auth status \| jq .` は正常に動作 | +| [#562](https://github.com/googleworkspace/cli/issues/562) — 対話 TUI が `cloud-platform` スコープを無条件に注入し、Workspace の admin policy で制限される組織では login が失敗する | 対応済 | `run_discovery_scope_picker` の選択後 auto-inject を削除(`auth_commands.rs`)。`cloud-platform` が必要な用途(modelarmor 等)は picker で明示選択するか `--full` / `--scopes` で指定する | ## upstream MCP 定点観測 diff --git a/FORK.md b/FORK.md index 6c566030..9abaa6c6 100644 --- a/FORK.md +++ b/FORK.md @@ -99,6 +99,7 @@ Bug reports and feature requests that targeted upstream's MCP server (closed whe | [#573](https://github.com/googleworkspace/cli/issues/573) — `metadataHeaders` array not expanded as repeated query params in `gmail.users.messages.get` | Fixed | Discovery parser preserves `repeated: true` (`discovery.rs`), and the executor expands JSON array values into multiple query entries (`executor.rs`). Discovery-driven MCP tools inherit the same behavior | | [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service not registered in `services.rs` (helper unreachable) | Fixed | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` is registered, so `gws script ...` and MCP `script_*` tools resolve correctly | | [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` prints non-JSON to stdout, breaking `jq` pipelines | Fixed | `Using keyring backend: ` is emitted via `eprintln!` to stderr (`credential_store.rs`), so `gws auth status \| jq .` parses cleanly | +| [#562](https://github.com/googleworkspace/cli/issues/562) — Interactive TUI unconditionally injects `cloud-platform` scope, breaking org-restricted accounts | Fixed | Removed the post-selection auto-inject in `run_discovery_scope_picker` (`auth_commands.rs`). Users who need `cloud-platform` (e.g. for modelarmor) can tick it in the picker or pass `--full` / explicit `--scopes` | ## Upstream MCP timeline diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 087195fe..4fda6eeb 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -930,7 +930,7 @@ fn run_discovery_scope_picker( relevant_scopes: &[crate::setup::DiscoveredScope], services_filter: Option<&HashSet>, ) -> Option> { - use crate::setup::{ScopeClassification, PLATFORM_SCOPE}; + use crate::setup::ScopeClassification; use crate::setup_tui::{PickerResult, SelectItem}; let mut recommended_scopes = vec![]; @@ -1102,10 +1102,12 @@ fn run_discovery_scope_picker( } } - // Always include cloud-platform scope - if !selected.contains(&PLATFORM_SCOPE.to_string()) { - selected.push(PLATFORM_SCOPE.to_string()); - } + // Do not auto-inject cloud-platform. It is a restricted scope that + // some Workspace orgs block via admin policy, which would cause + // `admin_policy_enforced` login failures for users who picked + // narrower scopes (upstream #562). Users who need cloud-platform + // (e.g. for the modelarmor helper) can tick it in the picker or + // pass `--full` / `--scopes https://www.googleapis.com/auth/cloud-platform`. // Hierarchical dedup: if we have both a broad scope (e.g. `.../auth/drive`) // and a narrower scope (e.g. `.../auth/drive.metadata`, `.../auth/drive.readonly`), @@ -1791,6 +1793,34 @@ mod tests { assert_eq!(scopes.len(), FULL_SCOPES.len()); } + /// `DEFAULT_SCOPES` must not include cloud-platform: it is a restricted + /// scope that some Workspace admin policies block, and the login flow + /// relies on this invariant to avoid `admin_policy_enforced` errors + /// for users who did not opt in (upstream #562). + #[test] + fn default_scopes_does_not_include_cloud_platform() { + for scope in DEFAULT_SCOPES { + assert!( + !scope.contains("cloud-platform"), + "DEFAULT_SCOPES must not include cloud-platform (upstream #562): {}", + scope + ); + } + } + + /// `FULL_SCOPES` is the opt-in path for users who do want cloud-platform + /// (e.g. for the modelarmor helper). Keep this invariant so we don't + /// accidentally remove the only non-custom entry point that includes it. + #[test] + fn full_scopes_includes_cloud_platform() { + assert!( + FULL_SCOPES + .iter() + .any(|s| *s == "https://www.googleapis.com/auth/cloud-platform"), + "FULL_SCOPES must include cloud-platform as the opt-in entry point" + ); + } + #[test] #[serial_test::serial] fn resolve_client_credentials_from_env_vars() { diff --git a/crates/google-workspace-cli/src/setup.rs b/crates/google-workspace-cli/src/setup.rs index 9ebd19cb..f25f7ae6 100644 --- a/crates/google-workspace-cli/src/setup.rs +++ b/crates/google-workspace-cli/src/setup.rs @@ -235,8 +235,6 @@ pub enum ScopeClassification { Restricted, } -pub const PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform"; - /// A scope discovered from a Discovery Document. #[derive(Clone)] pub struct DiscoveredScope { From b529991434dbcd5c0f6aeb921d232a1ab12f58e1 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 18 Apr 2026 23:01:43 +0900 Subject: [PATCH 20/44] fix(gmail): use OIDC userinfo for display name lookup (upstream #644) (#4) * fix(gmail): use OIDC userinfo for display name lookup (upstream #644) `gmail +send` printed "grant the profile scope" and sent messages with a null From display name even when `userinfo.profile` was granted. The root cause was the People API call (`/people/me?personFields=names`) returning 403 for some personal Gmail accounts that have the scope, which the helper treated as "scope missing". Switch to the OIDC userinfo endpoint (`https://openidconnect.googleapis.com/v1/userinfo`), which accepts the same `userinfo.profile` scope and responds uniformly across Workspace and personal accounts. The response is a flat object with a top-level `name` string, so `parse_profile_display_name` is simplified accordingly. Also reword the 401/403 fallback: it used to diagnose the failure as a missing scope, which was misleading when the scope was already granted. The new message points at `gws auth status` / `gws auth login` and frames the refresh path without asserting what went wrong. Tests updated to use the OIDC userinfo response shape. * style: cargo fmt --all on gmail helper CI uses `cargo fmt --all --check`; local `cargo fmt` missed two spots in the resolve_sender match guard and the fetch_profile_display_name json parse chain. No behavior change. --- .changeset/gmail-profile-userinfo-endpoint.md | 5 ++ FORK.ja.md | 1 + FORK.md | 1 + .../src/helpers/gmail/mod.rs | 88 +++++++++++-------- 4 files changed, 58 insertions(+), 37 deletions(-) create mode 100644 .changeset/gmail-profile-userinfo-endpoint.md diff --git a/.changeset/gmail-profile-userinfo-endpoint.md b/.changeset/gmail-profile-userinfo-endpoint.md new file mode 100644 index 00000000..936edf1e --- /dev/null +++ b/.changeset/gmail-profile-userinfo-endpoint.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(gmail): switch display-name lookup in `gmail +send` from People API to the OIDC userinfo endpoint (`https://openidconnect.googleapis.com/v1/userinfo`). The People API path returned 403 on some personal Gmail accounts even when `userinfo.profile` was granted, which made the "grant the profile scope" tip fire for users who already had the scope, and sent messages with a null From display name (upstream #644). The userinfo endpoint accepts the same `userinfo.profile` scope and behaves uniformly across Workspace and personal accounts. The 401/403 fallback message was also reworded so it no longer misdiagnoses transient permission denials as a missing scope. diff --git a/FORK.ja.md b/FORK.ja.md index 5eb42232..dd5a0e03 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -100,6 +100,7 @@ upstream の MCP サーバーに対するバグ報告・機能要望(MCP 削 | [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service が `services.rs` に未登録で helper が到達不能 | 対応済 | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` として登録済み。`gws script ...` と MCP `script_*` ツールが正常に解決する | | [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` が非 JSON を stdout に出力し `jq` パイプラインを破壊 | 対応済 | `Using keyring backend: ` は `credential_store.rs` で `eprintln!`(stderr)に出力される。`gws auth status \| jq .` は正常に動作 | | [#562](https://github.com/googleworkspace/cli/issues/562) — 対話 TUI が `cloud-platform` スコープを無条件に注入し、Workspace の admin policy で制限される組織では login が失敗する | 対応済 | `run_discovery_scope_picker` の選択後 auto-inject を削除(`auth_commands.rs`)。`cloud-platform` が必要な用途(modelarmor 等)は picker で明示選択するか `--full` / `--scopes` で指定する | +| [#644](https://github.com/googleworkspace/cli/issues/644) — `gmail +send` が `userinfo.profile` スコープを付与済みでも「grant profile scope」ヒントを出し、From の表示名が null になる | 対応済 | `helpers/gmail/mod.rs` の表示名取得を People API (`/people/me?personFields=names`) から OIDC userinfo endpoint (`openidconnect.googleapis.com/v1/userinfo`) に変更。同じスコープで Workspace / 個人 Gmail どちらでも一貫したレスポンスが得られる。401/403 時のフォールバックメッセージも、一時的な拒否をスコープ欠落と誤診断しない表現に改訂 | ## upstream MCP 定点観測 diff --git a/FORK.md b/FORK.md index 9abaa6c6..b167d834 100644 --- a/FORK.md +++ b/FORK.md @@ -100,6 +100,7 @@ Bug reports and feature requests that targeted upstream's MCP server (closed whe | [#625](https://github.com/googleworkspace/cli/issues/625) — `script` service not registered in `services.rs` (helper unreachable) | Fixed | `ServiceEntry { aliases: &["script"], api_name: "script", version: "v1", ... }` is registered, so `gws script ...` and MCP `script_*` tools resolve correctly | | [#717](https://github.com/googleworkspace/cli/issues/717) — `gws auth status` prints non-JSON to stdout, breaking `jq` pipelines | Fixed | `Using keyring backend: ` is emitted via `eprintln!` to stderr (`credential_store.rs`), so `gws auth status \| jq .` parses cleanly | | [#562](https://github.com/googleworkspace/cli/issues/562) — Interactive TUI unconditionally injects `cloud-platform` scope, breaking org-restricted accounts | Fixed | Removed the post-selection auto-inject in `run_discovery_scope_picker` (`auth_commands.rs`). Users who need `cloud-platform` (e.g. for modelarmor) can tick it in the picker or pass `--full` / explicit `--scopes` | +| [#644](https://github.com/googleworkspace/cli/issues/644) — `gmail +send` prints "grant profile scope" tip and sends with null From name even when `userinfo.profile` is granted | Fixed | Switched display-name lookup in `helpers/gmail/mod.rs` from People API (`/people/me?personFields=names`) to the OIDC userinfo endpoint (`openidconnect.googleapis.com/v1/userinfo`), which accepts the same scope and responds consistently across Workspace and personal Gmail accounts. Reworded the 401/403 fallback so it doesn't misdiagnose a transient permission denial as a missing scope | ## Upstream MCP timeline diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index 3a119920..292bcfed 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -622,16 +622,30 @@ pub(super) async fn resolve_sender( result = Some(vec![Mailbox::parse(&raw)]); } Ok(None) => {} - Err(e) if matches!(&e, GwsError::Api { code: 403, .. }) => { - // Token exists but doesn't carry the scope. + Err(e) + if matches!( + &e, + GwsError::Api { + code: 401 | 403, + .. + } + ) => + { + // Access token was minted without the profile scope, or + // the scope was revoked. If `gws auth status` shows + // `userinfo.profile` in `scopes`, re-running + // `gws auth login` refreshes the cached token and clears + // the mismatch; otherwise add the scope explicitly. eprintln!( - "Tip: run `gws auth login` and grant the \"profile\" scope \ - to include your display name in the From header" + "Note: userinfo endpoint denied the profile request. \ + Re-run `gws auth login` to refresh credentials; if the \ + \"profile\" scope is missing from `gws auth status`, \ + grant it there. Sending From header without display name." ); } Err(e) => { eprintln!( - "Note: could not fetch display name from People API ({})", + "Note: could not fetch display name from userinfo endpoint ({})", sanitize_for_terminal(&e.to_string()) ); } @@ -643,20 +657,26 @@ pub(super) async fn resolve_sender( Ok(result) } -/// Fetch the authenticated user's display name from the People API. -/// Requires a token with the `profile` scope. +/// Fetch the authenticated user's display name from the OIDC userinfo endpoint. +/// Requires a token carrying the `userinfo.profile` (or standard `profile`) scope. +/// +/// Upstream #644: the previous implementation used People API +/// (`/people/me?personFields=names`), which returns 403 for some personal +/// Gmail accounts even when `userinfo.profile` is granted — producing a +/// misleading "grant the profile scope" tip for users who already had it. +/// The OIDC userinfo endpoint accepts the same `userinfo.profile` scope +/// and responds uniformly across Workspace and personal accounts. async fn fetch_profile_display_name( client: &reqwest::Client, token: &str, ) -> Result, GwsError> { let resp = crate::client::send_with_retry(|| { client - .get("https://people.googleapis.com/v1/people/me") - .query(&[("personFields", "names")]) + .get("https://openidconnect.googleapis.com/v1/userinfo") .bearer_auth(token) }) .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("People API request failed: {e}")))?; + .map_err(|e| GwsError::Other(anyhow::anyhow!("userinfo request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status().as_u16(); @@ -664,22 +684,21 @@ async fn fetch_profile_display_name( .text() .await .unwrap_or_else(|_| "(error body unreadable)".to_string()); - return Err(build_api_error(status, &body, "People API request failed")); + return Err(build_api_error(status, &body, "userinfo request failed")); } - let body: Value = resp.json().await.map_err(|e| { - GwsError::Other(anyhow::anyhow!("Failed to parse People API response: {e}")) - })?; + let body: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse userinfo response: {e}")))?; Ok(parse_profile_display_name(&body)) } -/// Extract the display name from a People API `people.get` response. +/// Extract the display name from an OIDC userinfo response. +/// The response is a flat object with a top-level `name` string. fn parse_profile_display_name(body: &Value) -> Option { - body.get("names") - .and_then(|v| v.as_array()) - .and_then(|names| names.first()) - .and_then(|n| n.get("displayName")) + body.get("name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(sanitize_control_chars) @@ -3432,23 +3451,20 @@ mod tests { assert!(addrs[0].name.is_none()); } - // --- parse_profile_display_name tests --- + // --- parse_profile_display_name tests (OIDC userinfo responses) --- #[test] fn test_parse_profile_display_name() { + // OIDC userinfo response shape — flat object with top-level `name`. let body = serde_json::json!({ - "resourceName": "people/112118466613566642951", - "etag": "%EgUBAi43PRoEAQIFByIMR0xCc0FMcVBJQmc9", - "names": [{ - "metadata": { - "primary": true, - "source": { "type": "DOMAIN_PROFILE", "id": "112118466613566642951" } - }, - "displayName": "Malo Bourgon", - "familyName": "Bourgon", - "givenName": "Malo", - "displayNameLastFirst": "Bourgon, Malo" - }] + "sub": "112118466613566642951", + "name": "Malo Bourgon", + "given_name": "Malo", + "family_name": "Bourgon", + "picture": "https://lh3.googleusercontent.com/a/example", + "email": "malo@example.com", + "email_verified": true, + "locale": "en" }); assert_eq!( parse_profile_display_name(&body).as_deref(), @@ -3629,15 +3645,13 @@ mod tests { #[test] fn test_parse_profile_display_name_empty_name() { - let body = serde_json::json!({ - "names": [{ "displayName": "" }] - }); + let body = serde_json::json!({ "sub": "x", "name": "" }); assert!(parse_profile_display_name(&body).is_none()); } #[test] - fn test_parse_profile_display_name_no_names_array() { - let body = serde_json::json!({ "names": "not-an-array" }); + fn test_parse_profile_display_name_name_not_string() { + let body = serde_json::json!({ "sub": "x", "name": 42 }); assert!(parse_profile_display_name(&body).is_none()); } From 6bd99b8a32a60174f80b55767a79096473e9caf1 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 15:33:42 +0900 Subject: [PATCH 21/44] ci: add release workflow and Homebrew tap support - Add .github/workflows/release.yml: builds macOS/Linux binaries on fork/v* tag push and creates a GitHub Release. Supports workflow_dispatch for manually triggering a release from an existing tag. - Auto-updates shigechika/homebrew-tap Formula/gws-mcp.rb with correct SHA256 values after each release (requires HOMEBREW_TAP_TOKEN secret). - Update FORK.md / FORK.ja.md: add Homebrew as the recommended install method (no Rust toolchain required). --- .github/workflows/release.yml | 198 ++++++++++++++++++++++++++++++++++ FORK.ja.md | 14 ++- FORK.md | 16 ++- 3 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6b9fff93 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: Release + +on: + push: + tags: + - 'fork/v*' + workflow_dispatch: + inputs: + tag_name: + description: 'Tag to release (e.g. fork/v0.22.5-mcp.1)' + required: true + type: string + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + SCCACHE_IGNORE_SERVER_IO_ERROR: "true" + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-latest + target: x86_64-apple-darwin + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + targets: ${{ matrix.target }} + + - name: Setup sccache + id: sccache + uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 + continue-on-error: true + + - name: Enable sccache + if: steps.sccache.outcome == 'success' + shell: bash + run: | + if sccache --start-server 2>/dev/null; then + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + else + echo "::warning::sccache server failed to start, building without cache" + fi + + - name: Cache cargo + uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + with: + key: release-${{ matrix.target }} + cache-targets: "false" + + - name: Install cross-compilation tools (aarch64 Linux) + 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 + + - name: Package + run: | + tar -czf "gws-${{ matrix.target }}.tar.gz" \ + -C "target/${{ matrix.target }}/release" gws + sha256sum "gws-${{ matrix.target }}.tar.gz" \ + > "gws-${{ matrix.target }}.tar.gz.sha256" + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: gws-${{ matrix.target }} + path: | + gws-${{ matrix.target }}.tar.gz + gws-${{ matrix.target }}.tar.gz.sha256 + retention-days: 1 + + release: + name: Create Release and Update Formula + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download all artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + merge-multiple: true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ inputs.tag_name || github.ref_name }}" + VERSION="${TAG#fork/v}" + gh release create "$TAG" \ + --title "fork/v${VERSION}" \ + --notes "## Changes + +See [FORK.md](https://github.com/shigechika/gws-mcp/blob/main/FORK.md) for fork-specific changes. + +## Install + +### Homebrew (macOS / Linux) +\`\`\`bash +brew install shigechika/tap/gws-mcp +\`\`\` + +### Cargo +\`\`\`bash +cargo install --git https://github.com/shigechika/gws-mcp --locked +\`\`\` + +### Direct download +Download the archive for your platform below and place \`gws\` in your PATH." \ + gws-*.tar.gz \ + gws-*.tar.gz.sha256 + + - name: Update Homebrew formula + if: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + TAG: ${{ inputs.tag_name || github.ref_name }} + run: | + VERSION="${TAG#fork/v}" + TAG_ENCODED="${TAG/\//%2F}" + BASE_URL="https://github.com/shigechika/gws-mcp/releases/download/${TAG_ENCODED}" + + SHA_ARM64_DARWIN=$(awk '{print $1}' gws-aarch64-apple-darwin.tar.gz.sha256) + SHA_X86_64_DARWIN=$(awk '{print $1}' gws-x86_64-apple-darwin.tar.gz.sha256) + SHA_X86_64_LINUX=$(awk '{print $1}' gws-x86_64-unknown-linux-gnu.tar.gz.sha256) + SHA_ARM64_LINUX=$(awk '{print $1}' gws-aarch64-unknown-linux-gnu.tar.gz.sha256) + + cat > /tmp/gws-mcp.rb << FORMULA + class GwsMcp < Formula + desc "Google Workspace CLI with MCP server support" + homepage "https://github.com/shigechika/gws-mcp" + version "${VERSION}" + license "Apache-2.0" + + on_macos do + on_arm do + url "${BASE_URL}/gws-aarch64-apple-darwin.tar.gz" + sha256 "${SHA_ARM64_DARWIN}" + end + on_intel do + url "${BASE_URL}/gws-x86_64-apple-darwin.tar.gz" + sha256 "${SHA_X86_64_DARWIN}" + end + end + + on_linux do + on_arm do + url "${BASE_URL}/gws-aarch64-unknown-linux-gnu.tar.gz" + sha256 "${SHA_ARM64_LINUX}" + end + on_intel do + url "${BASE_URL}/gws-x86_64-unknown-linux-gnu.tar.gz" + sha256 "${SHA_X86_64_LINUX}" + end + end + + def install + bin.install "gws" + end + + test do + assert_match version.to_s, shell_output("#{bin}/gws --version") + end + end + FORMULA + + # Commit to homebrew-tap + gh api repos/shigechika/homebrew-tap/contents/Formula/gws-mcp.rb \ + --method PUT \ + --field message="update: gws-mcp formula to ${VERSION}" \ + --field content="$(base64 -w0 /tmp/gws-mcp.rb)" \ + --field sha="$(gh api repos/shigechika/homebrew-tap/contents/Formula/gws-mcp.rb --jq '.sha' 2>/dev/null || echo '')" \ + 2>/dev/null || \ + gh api repos/shigechika/homebrew-tap/contents/Formula/gws-mcp.rb \ + --method PUT \ + --field message="add: gws-mcp formula ${VERSION}" \ + --field content="$(base64 -w0 /tmp/gws-mcp.rb)" diff --git a/FORK.ja.md b/FORK.ja.md index dd5a0e03..b187e259 100644 --- a/FORK.ja.md +++ b/FORK.ja.md @@ -12,7 +12,7 @@ upstream が削除した **MCP(Model Context Protocol)サーバー機能** |---|---|---| | MCP サーバー (`gws mcp`) | 削除済み | 維持・メンテナンス中 | | MCP helper tools (`--helpers`) | なし | `gmail_send` 等を独自実装 | -| CI/CD ワークフロー | upstream 環境依存 | 最小構成(CI + Policy + Sync) | +| CI/CD ワークフロー | upstream 環境依存 | 最小構成(CI + Policy + Sync + Release) | ### MCP サーバー @@ -40,10 +40,18 @@ gws mcp -s gmail --tool-mode compact ## インストール -upstream の npm パッケージには MCP 機能が含まれていないため、ソースからビルドしてください。 +### Homebrew(macOS / Linux)— 推奨 ```bash -# GitHub から直接インストール(推奨) +brew install shigechika/tap/gws-mcp +``` + +Rust ツールチェーン不要。macOS(Apple Silicon / Intel)と Linux(x86\_64 / arm64)向けのバイナリを事前ビルドして配布しています。 + +### Cargo(ソースからビルド) + +```bash +# GitHub から直接インストール cargo install --git https://github.com/shigechika/gws-mcp --locked ``` diff --git a/FORK.md b/FORK.md index b167d834..d35fe198 100644 --- a/FORK.md +++ b/FORK.md @@ -12,7 +12,7 @@ It maintains the **MCP (Model Context Protocol) server** that upstream removed, |---|---|---| | MCP server (`gws mcp`) | Removed | Maintained | | MCP helper tools (`--helpers`) | N/A | `gmail_send` and more | -| CI/CD workflows | Upstream-specific | Minimal (CI + Policy + Sync) | +| CI/CD workflows | Upstream-specific | Minimal (CI + Policy + Sync + Release) | ### MCP server @@ -40,14 +40,22 @@ Enabled with the `--helpers` flag. These provide high-level operations on top of ## Installation -The upstream npm package does not include MCP support. Build from source: +### Homebrew (macOS / Linux) — recommended ```bash -# Install directly from GitHub (recommended) +brew install shigechika/tap/gws-mcp +``` + +No Rust toolchain required. Binaries are pre-built for macOS (Apple Silicon / Intel) and Linux (x86\_64 / arm64). + +### Cargo (from source) + +```bash +# Install directly from GitHub cargo install --git https://github.com/shigechika/gws-mcp --locked ``` -If you cloned the repository locally, install from the working tree: +If you cloned the repository locally: ```bash cd gws-mcp From d0512824676aa9254e82b8dc56d6ea0fc3490468 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 15:43:33 +0900 Subject: [PATCH 22/44] chore(deps): bump rustls-webpki to 0.103.13 for RUSTSEC-2026-0104 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db3c8231..3d3f77f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2260,9 +2260,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", From ba63f2c437ecd69f624781b4fa3ecd48d2d37ed3 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 16:13:03 +0900 Subject: [PATCH 23/44] fix(release): exclude branch pushes and fix sha256sum for macOS --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b9fff93..caedea1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: tags: - 'fork/v*' + branches-ignore: + - '**' workflow_dispatch: inputs: tag_name: @@ -78,8 +80,11 @@ jobs: run: | tar -czf "gws-${{ matrix.target }}.tar.gz" \ -C "target/${{ matrix.target }}/release" gws - sha256sum "gws-${{ matrix.target }}.tar.gz" \ - > "gws-${{ matrix.target }}.tar.gz.sha256" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "gws-${{ matrix.target }}.tar.gz" > "gws-${{ matrix.target }}.tar.gz.sha256" + else + shasum -a 256 "gws-${{ matrix.target }}.tar.gz" > "gws-${{ matrix.target }}.tar.gz.sha256" + fi - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 From 1e182eeb52a31c09a660dbc8587c00119e75facf Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 16:39:00 +0900 Subject: [PATCH 24/44] ci: add auto-tag workflow to version fork releases automatically --- .github/workflows/auto-tag.yml | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/auto-tag.yml diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 00000000..c2a332bc --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,43 @@ +name: Auto Tag + +on: + push: + branches: + - main + paths: + - 'crates/**/*.rs' + - 'crates/**/Cargo.toml' + - 'Cargo.lock' + +permissions: + contents: write + +jobs: + tag: + name: Create fork tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Compute and push next tag + run: | + VERSION=$(grep '^version' crates/google-workspace-cli/Cargo.toml \ + | head -1 | grep -oP '[\d]+\.[\d]+\.[\d]+') + + LAST=$(git tag -l "fork/v${VERSION}-mcp.*" | sort -V | tail -1) + if [ -z "$LAST" ]; then + N=1 + else + N=$(echo "$LAST" | grep -oP '\d+$') + N=$((N + 1)) + fi + + NEW_TAG="fork/v${VERSION}-mcp.${N}" + echo "Creating tag: $NEW_TAG" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$NEW_TAG" + git push origin "$NEW_TAG" From 67073995556102f4fc5e5b3b48b9bac20fb6dc9f Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 16:39:36 +0900 Subject: [PATCH 25/44] chore(deps): update dependencies --- Cargo.lock | 247 ++++++++++++++++++++++++++++------------------------- 1 file changed, 133 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d3f77f7..a5f1d3da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,9 +178,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -404,7 +404,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -693,9 +693,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filedescriptor" @@ -934,7 +934,7 @@ dependencies = [ "mail-builder", "mime_guess2", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "ratatui", "reqwest", "serde", @@ -993,6 +993,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -1085,16 +1091,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1149,12 +1154,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1162,9 +1168,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1175,9 +1181,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1189,15 +1195,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1209,15 +1215,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1253,9 +1259,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1263,12 +1269,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1343,9 +1349,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -1398,15 +1404,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -1417,7 +1423,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -1428,9 +1434,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -1455,9 +1461,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -1559,7 +1565,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -1767,7 +1773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -1829,9 +1835,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1899,7 +1905,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1948,9 +1954,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1959,9 +1965,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2025,7 +2031,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -2077,7 +2083,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.16.1", "indoc", "instability", @@ -2096,7 +2102,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -2215,7 +2221,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2224,9 +2230,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "once_cell", "ring", @@ -2250,9 +2256,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2323,7 +2329,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2336,7 +2342,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2355,9 +2361,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2585,6 +2591,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -2669,7 +2681,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.11.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2787,9 +2799,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2812,9 +2824,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2829,9 +2841,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2923,7 +2935,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2960,11 +2972,12 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", + "symlink", "thiserror 2.0.18", "time", "tracing-subscriber", @@ -3041,9 +3054,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -3134,9 +3147,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "atomic", "getrandom 0.4.2", @@ -3183,11 +3196,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3196,14 +3209,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3214,9 +3227,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.66" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3224,9 +3237,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3234,9 +3247,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -3247,9 +3260,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -3295,7 +3308,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -3303,9 +3316,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.93" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3714,6 +3727,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -3763,7 +3782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3795,15 +3814,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3812,9 +3831,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3869,18 +3888,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3910,9 +3929,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3921,9 +3940,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3932,9 +3951,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", From 2a70fd0f8b97b73ffcb420dbb3d559e582b17bb5 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 17:21:26 +0900 Subject: [PATCH 26/44] fix(ci): use PAT for tag push so release workflow is triggered --- .github/workflows/auto-tag.yml | 4 +++- .github/workflows/release.yml | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index c2a332bc..86aac855 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -22,6 +22,8 @@ jobs: fetch-depth: 0 - name: Compute and push next tag + env: + GH_TOKEN: ${{ secrets.GWS_MCP_RELEASE_TOKEN }} run: | VERSION=$(grep '^version' crates/google-workspace-cli/Cargo.toml \ | head -1 | grep -oP '[\d]+\.[\d]+\.[\d]+') @@ -40,4 +42,4 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$NEW_TAG" - git push origin "$NEW_TAG" + git push "https://x-access-token:${GH_TOKEN}@github.com/shigechika/gws-mcp.git" "$NEW_TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caedea1c..354b50f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,6 @@ on: push: tags: - 'fork/v*' - branches-ignore: - - '**' workflow_dispatch: inputs: tag_name: From f7ffb44aafd73d18fe77890a54b83294f8d819ed Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 17:21:39 +0900 Subject: [PATCH 27/44] chore(deps): update dependencies --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5f1d3da..b47d5a5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,9 +1349,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -3214,9 +3214,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -3227,9 +3227,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -3237,9 +3237,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3247,9 +3247,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -3260,9 +3260,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -3316,9 +3316,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", From fa0c0dde516a0fbd00bed194437640fe6ce312a9 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 17:23:29 +0900 Subject: [PATCH 28/44] fix(ci): use correct secret name GH_PAT for tag push --- .github/workflows/auto-tag.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 86aac855..fc959e0f 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -20,10 +20,9 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + token: ${{ secrets.GH_PAT }} - name: Compute and push next tag - env: - GH_TOKEN: ${{ secrets.GWS_MCP_RELEASE_TOKEN }} run: | VERSION=$(grep '^version' crates/google-workspace-cli/Cargo.toml \ | head -1 | grep -oP '[\d]+\.[\d]+\.[\d]+') @@ -42,4 +41,4 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$NEW_TAG" - git push "https://x-access-token:${GH_TOKEN}@github.com/shigechika/gws-mcp.git" "$NEW_TAG" + git push origin "$NEW_TAG" From 42d4804540a4b4b95eb522c73d27d280a70fb724 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 17:23:49 +0900 Subject: [PATCH 29/44] ci: add workflow_dispatch to auto-tag for manual testing --- .github/workflows/auto-tag.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index fc959e0f..9d0a8383 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -8,6 +8,7 @@ on: - 'crates/**/*.rs' - 'crates/**/Cargo.toml' - 'Cargo.lock' + workflow_dispatch: permissions: contents: write From 6525d832278c465a46572060f90f5d8189600346 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 17:58:07 +0900 Subject: [PATCH 30/44] ci: rename release workflow to force GitHub re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename release.yml → release-fork.yml so GitHub assigns a new workflow ID with fresh trigger metadata (the old ID was frozen from the upstream file that had no workflow_dispatch or fork/v* tag triggers). Also fix two bugs in the same pass: - Use GH_PAT instead of HOMEBREW_TAP_TOKEN (actual secret name) - Use github.event.inputs.tag_name instead of inputs.tag_name --- .github/workflows/{release.yml => release-fork.yml} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{release.yml => release-fork.yml} (96%) diff --git a/.github/workflows/release.yml b/.github/workflows/release-fork.yml similarity index 96% rename from .github/workflows/release.yml rename to .github/workflows/release-fork.yml index 354b50f2..3db276bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-fork.yml @@ -109,7 +109,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - TAG="${{ inputs.tag_name || github.ref_name }}" + TAG="${{ github.event.inputs.tag_name || github.ref_name }}" VERSION="${TAG#fork/v}" gh release create "$TAG" \ --title "fork/v${VERSION}" \ @@ -135,10 +135,10 @@ Download the archive for your platform below and place \`gws\` in your PATH." \ gws-*.tar.gz.sha256 - name: Update Homebrew formula - if: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} + if: ${{ secrets.GH_PAT != '' }} env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - TAG: ${{ inputs.tag_name || github.ref_name }} + GH_TOKEN: ${{ secrets.GH_PAT }} + TAG: ${{ github.event.inputs.tag_name || github.ref_name }} run: | VERSION="${TAG#fork/v}" TAG_ENCODED="${TAG/\//%2F}" From 2adc983b1212615dfd7f5ce87b40eefd0b8bbe04 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 18:04:44 +0900 Subject: [PATCH 31/44] ci: add minimal test workflow for dispatch verification --- .github/workflows/test-minimal.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/workflows/test-minimal.yml diff --git a/.github/workflows/test-minimal.yml b/.github/workflows/test-minimal.yml new file mode 100644 index 00000000..3c639a96 --- /dev/null +++ b/.github/workflows/test-minimal.yml @@ -0,0 +1,8 @@ +name: Test Minimal +on: + workflow_dispatch: +jobs: + hello: + runs-on: ubuntu-latest + steps: + - run: echo hi From efe78e60db003da2d06db8b6e44d4be5eb1b24d0 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 18:06:55 +0900 Subject: [PATCH 32/44] fix(ci): fix YAML parse error in release-fork.yml The --notes inline string had unindented lines (col 0) which caused YAML to terminate the run: | block scalar early, preventing GitHub from parsing the workflow name and triggers. Switch to --notes-file with a heredoc so all block scalar lines stay at the 10-space indentation level required by YAML. --- .github/workflows/release-fork.yml | 33 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 3db276bf..bd155fa9 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -111,26 +111,29 @@ jobs: run: | TAG="${{ github.event.inputs.tag_name || github.ref_name }}" VERSION="${TAG#fork/v}" - gh release create "$TAG" \ - --title "fork/v${VERSION}" \ - --notes "## Changes + cat > /tmp/release-notes.md << 'RELNOTES' + ## Changes -See [FORK.md](https://github.com/shigechika/gws-mcp/blob/main/FORK.md) for fork-specific changes. + See [FORK.md](https://github.com/shigechika/gws-mcp/blob/main/FORK.md) for fork-specific changes. -## Install + ## Install -### Homebrew (macOS / Linux) -\`\`\`bash -brew install shigechika/tap/gws-mcp -\`\`\` + ### Homebrew (macOS / Linux) + ```bash + brew install shigechika/tap/gws-mcp + ``` -### Cargo -\`\`\`bash -cargo install --git https://github.com/shigechika/gws-mcp --locked -\`\`\` + ### Cargo + ```bash + cargo install --git https://github.com/shigechika/gws-mcp --locked + ``` -### Direct download -Download the archive for your platform below and place \`gws\` in your PATH." \ + ### Direct download + Download the archive for your platform below and place `gws` in your PATH. + RELNOTES + gh release create "$TAG" \ + --title "fork/v${VERSION}" \ + --notes-file /tmp/release-notes.md \ gws-*.tar.gz \ gws-*.tar.gz.sha256 From e2052b18b77bcf4aa1659eaad77c6e5d7a70b80d Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 18:10:31 +0900 Subject: [PATCH 33/44] fix(ci): check GH_PAT in shell instead of if condition GitHub Actions does not allow secrets context in step if: conditions. Use a shell guard ([[ -z $GH_TOKEN ]]) instead. --- .github/workflows/release-fork.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index bd155fa9..20b0269a 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -138,11 +138,11 @@ jobs: gws-*.tar.gz.sha256 - name: Update Homebrew formula - if: ${{ secrets.GH_PAT != '' }} env: GH_TOKEN: ${{ secrets.GH_PAT }} TAG: ${{ github.event.inputs.tag_name || github.ref_name }} run: | + [[ -z "$GH_TOKEN" ]] && echo "GH_PAT not set, skipping formula update" && exit 0 VERSION="${TAG#fork/v}" TAG_ENCODED="${TAG/\//%2F}" BASE_URL="https://github.com/shigechika/gws-mcp/releases/download/${TAG_ENCODED}" From a24a25c37701df1cdc8551918dc100c1d6ee22d4 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 18:18:01 +0900 Subject: [PATCH 34/44] ci: remove test workflow --- .github/workflows/test-minimal.yml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .github/workflows/test-minimal.yml diff --git a/.github/workflows/test-minimal.yml b/.github/workflows/test-minimal.yml deleted file mode 100644 index 3c639a96..00000000 --- a/.github/workflows/test-minimal.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Test Minimal -on: - workflow_dispatch: -jobs: - hello: - runs-on: ubuntu-latest - steps: - - run: echo hi From 60badb5ad5ec8a48e224729a3f4da9450d4d646e Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Tue, 28 Apr 2026 18:20:54 +0900 Subject: [PATCH 35/44] fix(ci): use HOMEBREW_TAP_TOKEN for homebrew-tap write access GH_PAT is for auto-tag push only; HOMEBREW_TAP_TOKEN is the dedicated secret with write access to shigechika/homebrew-tap. --- .github/workflows/release-fork.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 20b0269a..648d99d9 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -139,10 +139,10 @@ jobs: - name: Update Homebrew formula env: - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} TAG: ${{ github.event.inputs.tag_name || github.ref_name }} run: | - [[ -z "$GH_TOKEN" ]] && echo "GH_PAT not set, skipping formula update" && exit 0 + [[ -z "$GH_TOKEN" ]] && echo "HOMEBREW_TAP_TOKEN not set, skipping formula update" && exit 0 VERSION="${TAG#fork/v}" TAG_ENCODED="${TAG/\//%2F}" BASE_URL="https://github.com/shigechika/gws-mcp/releases/download/${TAG_ENCODED}" From ff247b1ebc56c09d105f0854f95ec2c99d16239b Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 2 May 2026 20:05:08 +0900 Subject: [PATCH 36/44] fix(auth): map meet service to meetings scope prefix (#7) * fix(auth): map meet service to meetings scope prefix gws auth login -s meet silently requested zero Meet scopes because the Meet API exposes scopes under the meetings.* prefix, not meet.*. Add the mapping so the existing dynamic-augmentation path picks up all Meet scopes from the Discovery document. Mirrors upstream PR #754. * chore: add changeset for meet scope fix --- .changeset/auth-meet-scope.md | 5 +++++ .../google-workspace-cli/src/auth_commands.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 .changeset/auth-meet-scope.md diff --git a/.changeset/auth-meet-scope.md b/.changeset/auth-meet-scope.md new file mode 100644 index 00000000..0899aee3 --- /dev/null +++ b/.changeset/auth-meet-scope.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix `gws auth login -s meet` not granting any Meet scopes. The Meet API exposes scopes under the `meetings.*` prefix (e.g. `meetings.space.readonly`), but the CLI service alias is `meet`, so the scope-picker filter matched nothing and silently dropped every Meet scope. Map the `meet` service to the `meetings` scope prefix so the existing dynamic-augmentation path pulls all Meet scopes from the Discovery document when `-s meet` is supplied. diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 4fda6eeb..4b4988b0 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -841,6 +841,7 @@ fn map_service_to_scope_prefixes(service: &str) -> Vec<&str> { "slides" => vec!["presentations"], "docs" => vec!["documents"], "people" => vec!["contacts", "directory"], + "meet" => vec!["meetings"], s => vec![s], } } @@ -2269,6 +2270,23 @@ mod tests { )); } + #[test] + fn scope_matches_service_meet() { + let services: HashSet = ["meet"].iter().map(|s| s.to_string()).collect(); + assert!(scope_matches_service( + "https://www.googleapis.com/auth/meetings.space.created", + &services + )); + assert!(scope_matches_service( + "https://www.googleapis.com/auth/meetings.space.readonly", + &services + )); + assert!(scope_matches_service( + "https://www.googleapis.com/auth/meetings.space.settings", + &services + )); + } + // ── services filter integration tests ──────────────────────────────── #[test] From 42235e7bfa2145d67afcb6f8668f2e838e7b1dba Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 08:14:10 +0900 Subject: [PATCH 37/44] fix(gmail): accept unpadded base64url from Gmail API (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gmail): accept unpadded base64url from Gmail API (upstream #774) Gmail API returns base64url data without `=` padding in attachment and message body responses. Switch decoders to URL_SAFE_NO_PAD with `.trim_end_matches('=')` so both padded and unpadded input are accepted. * fix(gmail): use Indifferent padding decoder for base64url (upstream #774) Replace URL_SAFE_NO_PAD + trim with a URL_SAFE_LENIENT const engine using DecodePaddingMode::Indifferent, which accepts both padded and unpadded input without preprocessing. Add changeset file for release tracking. * fix(gmail): simplify URL_SAFE_LENIENT — drop encode_padding, trim comment to one line --- .changeset/gmail-unpadded-base64url.md | 5 +++ .../src/helpers/gmail/mod.rs | 37 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/gmail-unpadded-base64url.md diff --git a/.changeset/gmail-unpadded-base64url.md b/.changeset/gmail-unpadded-base64url.md new file mode 100644 index 00000000..ad6c4eb7 --- /dev/null +++ b/.changeset/gmail-unpadded-base64url.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(gmail): accept unpadded base64url from Gmail API. Gmail API returns base64url data without `=` padding in attachment and message body responses (per spec). The previous decoder (`URL_SAFE`) required canonical padding and would silently return an error for real Gmail data with missing padding. Replaced with a custom `URL_SAFE_LENIENT` engine using `DecodePaddingMode::Indifferent`, which accepts both padded and unpadded input without any preprocessing (upstream #774). diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index 292bcfed..63eefe13 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -32,7 +32,16 @@ pub(super) use crate::error::GwsError; pub(super) use crate::executor; use crate::output::sanitize_for_terminal; pub(super) use anyhow::Context; -pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +pub(super) use base64::Engine as _; +/// URL-safe base64 decoder accepting both padded and unpadded input (Gmail API omits padding). +pub(super) const URL_SAFE_LENIENT: base64::engine::GeneralPurpose = + base64::engine::GeneralPurpose::new( + &base64::alphabet::URL_SAFE, + base64::engine::general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + ); +#[cfg(test)] +pub(super) use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; pub(super) use mail_builder::headers::address::Address as MbAddress; pub(super) use serde::Serialize; @@ -748,7 +757,7 @@ async fn fetch_attachment_data( )) })?; - URL_SAFE + URL_SAFE_LENIENT .decode(data_str) .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to decode attachment data: {e}"))) } @@ -840,7 +849,7 @@ struct PayloadContents { /// Decode a base64url-encoded text body part, returning the string on success. fn decode_text_body(data: &str, mime_label: &str) -> Option { - match URL_SAFE.decode(data) { + match URL_SAFE_LENIENT.decode(data) { Ok(decoded) => match String::from_utf8(decoded) { Ok(s) => Some(s), Err(e) => { @@ -3478,6 +3487,28 @@ mod tests { URL_SAFE.encode(s) } + fn base64url_no_pad(s: &str) -> String { + URL_SAFE_NO_PAD.encode(s) + } + + #[test] + fn test_extract_payload_contents_unpadded_base64() { + // Gmail API returns base64url without padding; ensure we decode it correctly. + let text_data = base64url_no_pad("Hello plain text"); + let html_data = base64url_no_pad("

Hello HTML

"); + assert!(!text_data.ends_with('='), "test data must be unpadded"); + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 16 } }, + { "mimeType": "text/html", "body": { "data": html_data, "size": 18 } }, + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.body_text.as_deref(), Some("Hello plain text")); + assert_eq!(contents.body_html.as_deref(), Some("

Hello HTML

")); + } + #[test] fn test_extract_payload_contents_simple() { let text_data = base64url("Hello plain text"); From 877bb96709d3a62556f384eda169250f8976dfd6 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 08:54:48 +0900 Subject: [PATCH 38/44] ci(release): add Windows .zip, .deb, .rpm to release artifacts (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(release): add Windows zip, .deb, and .rpm to release artifacts - Add x86_64-pc-windows-msvc to release build matrix; package as .zip - Generate .deb (cargo-deb) and .rpm (cargo-generate-rpm) for Linux targets - Add [package.metadata.deb] and [package.metadata.generate-rpm] to Cargo.toml - Update release notes to document new install methods * ci(release): fix generate-rpm package name, drop --locked, fix release note placeholders - cargo generate-rpm: -p crates/google-workspace-cli → -p google-workspace-cli (package name, not path) - Remove --locked from cargo install to avoid Cargo.lock absence errors - Release notes: VERSION → to clarify placeholders * fix(release): pin cargo-deb/generate-rpm versions and add RPM ca-certificates dependency - Pin cargo-deb to 3.7.0 and cargo-generate-rpm to 0.21.0 for reproducible builds - Add requires = { ca-certificates = "*" } to [package.metadata.generate-rpm] to match the `depends = "ca-certificates"` already present in .deb metadata --- .changeset/release-windows-deb-rpm.md | 5 ++ .github/workflows/release-fork.yml | 66 ++++++++++++++++++++++---- crates/google-workspace-cli/Cargo.toml | 18 +++++++ 3 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 .changeset/release-windows-deb-rpm.md diff --git a/.changeset/release-windows-deb-rpm.md b/.changeset/release-windows-deb-rpm.md new file mode 100644 index 00000000..7ff5ba93 --- /dev/null +++ b/.changeset/release-windows-deb-rpm.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +ci(release): add Windows (.zip), Debian (.deb), and RPM (.rpm) packages to release artifacts. Windows binary is packaged as a zip archive. Linux targets additionally generate .deb (via cargo-deb) and .rpm (via cargo-generate-rpm) packages alongside the existing .tar.gz archives. diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 648d99d9..1e388a58 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -34,6 +34,8 @@ jobs: target: x86_64-unknown-linux-gnu - os: ubuntu-latest target: aarch64-unknown-linux-gnu + - os: windows-latest + target: x86_64-pc-windows-msvc steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -69,12 +71,21 @@ jobs: sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Disable Windows Defender scanning for cargo + if: runner.os == 'Windows' + shell: pwsh + run: | + Add-MpPreference -ExclusionPath "$env:USERPROFILE\.cargo" + Add-MpPreference -ExclusionPath "$env:USERPROFILE\.rustup" + Add-MpPreference -ExclusionPath "${{ github.workspace }}\target" + - name: Build run: cargo build --release --target ${{ matrix.target }} env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - name: Package + - name: Package (Unix) + if: runner.os != 'Windows' run: | tar -czf "gws-${{ matrix.target }}.tar.gz" \ -C "target/${{ matrix.target }}/release" gws @@ -84,13 +95,40 @@ jobs: shasum -a 256 "gws-${{ matrix.target }}.tar.gz" > "gws-${{ matrix.target }}.tar.gz.sha256" fi + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path "target/${{ matrix.target }}/release/gws.exe" ` + -DestinationPath "gws-${{ matrix.target }}.zip" + $hash = (Get-FileHash "gws-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower() + "$hash gws-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "gws-${{ matrix.target }}.zip.sha256" + + - name: Generate .deb + if: runner.os == 'Linux' + run: | + cargo install cargo-deb@3.7.0 + cargo deb --no-build --no-strip --target ${{ matrix.target }} \ + -p google-workspace-cli + DEB=$(ls target/${{ matrix.target }}/debian/*.deb) + cp "$DEB" "gws-${{ matrix.target }}.deb" + sha256sum "gws-${{ matrix.target }}.deb" > "gws-${{ matrix.target }}.deb.sha256" + + - name: Generate .rpm + if: runner.os == 'Linux' + run: | + cargo install cargo-generate-rpm@0.21.0 + cargo generate-rpm --target ${{ matrix.target }} \ + -p google-workspace-cli + RPM=$(ls target/${{ matrix.target }}/generate-rpm/*.rpm) + cp "$RPM" "gws-${{ matrix.target }}.rpm" + sha256sum "gws-${{ matrix.target }}.rpm" > "gws-${{ matrix.target }}.rpm.sha256" + - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: gws-${{ matrix.target }} - path: | - gws-${{ matrix.target }}.tar.gz - gws-${{ matrix.target }}.tar.gz.sha256 + path: gws-${{ matrix.target }}.* retention-days: 1 release: @@ -123,19 +161,31 @@ jobs: brew install shigechika/tap/gws-mcp ``` + ### Debian / Ubuntu (.deb) + ```bash + sudo dpkg -i gws-mcp__amd64.deb + ``` + + ### RHEL / Fedora / Amazon Linux (.rpm) + ```bash + sudo rpm -i gws-mcp--1.x86_64.rpm + ``` + + ### Windows + Download `gws-x86_64-pc-windows-msvc.zip`, extract `gws.exe`, and place it in a directory on your PATH. + ### Cargo ```bash cargo install --git https://github.com/shigechika/gws-mcp --locked ``` - ### Direct download - Download the archive for your platform below and place `gws` in your PATH. + ### Direct download (macOS / Linux) + Download the `.tar.gz` archive for your platform below and place `gws` in your PATH. RELNOTES gh release create "$TAG" \ --title "fork/v${VERSION}" \ --notes-file /tmp/release-notes.md \ - gws-*.tar.gz \ - gws-*.tar.gz.sha256 + gws-* - name: Update Homebrew formula env: diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index 058b109e..bbaf632d 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -78,3 +78,21 @@ keyring = "3.6.3" [dev-dependencies] serial_test = "3.4.0" + +[package.metadata.deb] +name = "gws-mcp" +maintainer = "AIKAWA Shigechika " +copyright = "2026, Google LLC" +license-file = ["../../LICENSE", "0"] +extended-description = "Google Workspace CLI with MCP server support. Provides a stdio MCP server for Gmail, Drive, Calendar, and other Google Workspace APIs." +depends = "ca-certificates" +section = "utils" +priority = "optional" +assets = [["target/release/gws", "usr/bin/gws", "755"]] + +[package.metadata.generate-rpm] +name = "gws-mcp" +license = "Apache-2.0" +description = "Google Workspace CLI with MCP server support" +requires = { ca-certificates = "*" } +assets = [{ source = "target/release/gws", dest = "/usr/bin/gws", mode = "755" }] From 7c350cfd1af4f2d793b91afb36fba67a7fb144b2 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 09:08:37 +0900 Subject: [PATCH 39/44] fix(release): use --manifest-path for cargo generate-rpm (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(release): use --manifest-path for cargo generate-rpm in workspace cargo generate-rpm -p resolves the path as package-name/Cargo.toml which fails in a workspace where the crate lives under crates/. Switch to --manifest-path for explicit resolution. * ci(release): bump actions to Node.js 24-compatible versions - actions/checkout: v4 → v6.0.2 - mozilla-actions/sccache-action: v0.0.7 → v0.0.10 - Swatinem/rust-cache: v2 → v2.9.1 - actions/upload-artifact: v4 → v7.0.1 - actions/download-artifact: v4 → v8.0.1 --- .github/workflows/release-fork.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 1e388a58..940ddadc 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -37,7 +37,7 @@ jobs: - os: windows-latest target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -46,7 +46,7 @@ jobs: - name: Setup sccache id: sccache - uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 + uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 continue-on-error: true - name: Enable sccache @@ -60,7 +60,7 @@ jobs: fi - name: Cache cargo - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: release-${{ matrix.target }} cache-targets: "false" @@ -119,13 +119,13 @@ jobs: run: | cargo install cargo-generate-rpm@0.21.0 cargo generate-rpm --target ${{ matrix.target }} \ - -p google-workspace-cli + --manifest-path crates/google-workspace-cli/Cargo.toml RPM=$(ls target/${{ matrix.target }}/generate-rpm/*.rpm) cp "$RPM" "gws-${{ matrix.target }}.rpm" sha256sum "gws-${{ matrix.target }}.rpm" > "gws-${{ matrix.target }}.rpm.sha256" - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: gws-${{ matrix.target }} path: gws-${{ matrix.target }}.* @@ -136,10 +136,10 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download all artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true From cbee9a59bfea0244db5177214583e51c60a10603 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 09:41:51 +0900 Subject: [PATCH 40/44] fix(test): fix encryption key race + add deb/rpm/Windows install docs (#11) * fix(test): prevent race in encrypted-credentials tests; docs: add deb/rpm/Windows install - test_load_credentials_encrypted_file: replace raw set_var with EnvVarGuard so GOOGLE_WORKSPACE_CLI_CONFIG_DIR is restored before the tempdir is deleted - test_load_credentials_encrypted_takes_priority_over_default: add #[serial] and per-test config-dir isolation to eliminate the key-file creation race - FORK.md: add Debian .deb, RHEL .rpm, Windows .zip, and direct-download sections to the Installation chapter * chore: add changeset for test race fix and install docs * docs: fix deb filename placeholder to include revision suffix (-1) --- .changeset/fix-test-race-fork-install-docs.md | 5 +++ FORK.md | 35 +++++++++++++++++++ crates/google-workspace-cli/src/auth.rs | 8 +++-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-test-race-fork-install-docs.md diff --git a/.changeset/fix-test-race-fork-install-docs.md b/.changeset/fix-test-race-fork-install-docs.md new file mode 100644 index 00000000..3f0aca79 --- /dev/null +++ b/.changeset/fix-test-race-fork-install-docs.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(test): fix encryption key race in parallel tests; docs: add deb/rpm/Windows install sections to FORK.md. Replace raw set_var with EnvVarGuard in test_load_credentials_encrypted_file to prevent GOOGLE_WORKSPACE_CLI_CONFIG_DIR leaking into concurrent tests. Add serial attribute and per-test config-dir isolation to test_load_credentials_encrypted_takes_priority_over_default. diff --git a/FORK.md b/FORK.md index d35fe198..738fc1b7 100644 --- a/FORK.md +++ b/FORK.md @@ -48,6 +48,41 @@ brew install shigechika/tap/gws-mcp No Rust toolchain required. Binaries are pre-built for macOS (Apple Silicon / Intel) and Linux (x86\_64 / arm64). +### Debian / Ubuntu (.deb) + +```bash +sudo dpkg -i gws-mcp_-1_amd64.deb +# or for arm64: +sudo dpkg -i gws-mcp_-1_arm64.deb +``` + +Download the `.deb` file from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest). + +### RHEL / Fedora / Amazon Linux (.rpm) + +```bash +sudo rpm -i gws-mcp--1.x86_64.rpm +# or for aarch64: +sudo rpm -i gws-mcp--1.aarch64.rpm +``` + +Download the `.rpm` file from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest). + +### Windows + +Download `gws-x86_64-pc-windows-msvc.zip` from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest), extract `gws.exe`, and place it in a directory on your `PATH`. + +### Direct download (macOS / Linux) + +Download the `.tar.gz` archive for your platform from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest) and place `gws` in your `PATH`. + +| Platform | Archive | +|---|---| +| macOS (Apple Silicon) | `gws-aarch64-apple-darwin.tar.gz` | +| macOS (Intel) | `gws-x86_64-apple-darwin.tar.gz` | +| Linux x86\_64 | `gws-x86_64-unknown-linux-gnu.tar.gz` | +| Linux arm64 | `gws-aarch64-unknown-linux-gnu.tar.gz` | + ### Cargo (from source) ```bash diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4..7566e2da 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -763,8 +763,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); - // Isolate global config dir to prevent races with other tests - std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + // Isolate global config dir; guard restores the env var on drop. + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); // Encrypt and write let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); @@ -785,6 +785,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_takes_priority_over_default() { // Encrypted credentials should be loaded before the default plaintext path let enc_json = r#"{ @@ -804,6 +805,9 @@ mod tests { let enc_path = dir.path().join("credentials.enc"); let plain_path = dir.path().join("credentials.json"); + // Isolate global config dir; guard restores the env var on drop. + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); std::fs::write(&plain_path, plain_json).unwrap(); From 7f508028a930b09dcecd64261cd24a57b2a251fb Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 09:50:53 +0900 Subject: [PATCH 41/44] fix(release): use -p crates/google-workspace-cli for cargo generate-rpm (#12) --manifest-path is unsupported in cargo-generate-rpm 0.21.0. -p treats its argument as a directory path, so -p crates/google-workspace-cli resolves correctly to crates/google-workspace-cli/Cargo.toml. --- .changeset/fix-rpm-package-path.md | 5 +++++ .github/workflows/release-fork.yml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-rpm-package-path.md diff --git a/.changeset/fix-rpm-package-path.md b/.changeset/fix-rpm-package-path.md new file mode 100644 index 00000000..de3f3e33 --- /dev/null +++ b/.changeset/fix-rpm-package-path.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(release): use -p crates/google-workspace-cli for cargo generate-rpm. The --manifest-path flag is not supported in cargo-generate-rpm 0.21.0; -p treats its argument as a directory path, so the correct form is -p crates/google-workspace-cli (which resolves to crates/google-workspace-cli/Cargo.toml). diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 940ddadc..a5b5e9b0 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -119,7 +119,7 @@ jobs: run: | cargo install cargo-generate-rpm@0.21.0 cargo generate-rpm --target ${{ matrix.target }} \ - --manifest-path crates/google-workspace-cli/Cargo.toml + -p crates/google-workspace-cli RPM=$(ls target/${{ matrix.target }}/generate-rpm/*.rpm) cp "$RPM" "gws-${{ matrix.target }}.rpm" sha256sum "gws-${{ matrix.target }}.rpm" > "gws-${{ matrix.target }}.rpm.sha256" From 443814a81ae3f6cfcb790ee3a87fe29b2cf37f31 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 10:50:54 +0900 Subject: [PATCH 42/44] ci(release): use friendly asset names (linux-amd64, macos-arm64, etc.) (#13) * ci(release): use friendly asset names instead of raw Rust target triples Add asset_name to build matrix: aarch64-apple-darwin -> macos-arm64 x86_64-apple-darwin -> macos-amd64 x86_64-unknown-linux-gnu -> linux-amd64 aarch64-unknown-linux-gnu -> linux-arm64 x86_64-pc-windows-msvc -> windows-amd64 Update Homebrew formula URLs and FORK.md install commands to match. * ci(release): include version in asset filenames Format: gws-mcp--.ext e.g. gws-mcp-0.22.5-mcp.9-linux-amd64.tar.gz Version is derived from the tag at build time via GITHUB_ENV. Update Homebrew formula URLs and FORK.md accordingly. --- .changeset/fix-asset-naming.md | 5 +++ .github/workflows/release-fork.yml | 66 +++++++++++++++++++----------- FORK.md | 18 ++++---- 3 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 .changeset/fix-asset-naming.md diff --git a/.changeset/fix-asset-naming.md b/.changeset/fix-asset-naming.md new file mode 100644 index 00000000..081acf72 --- /dev/null +++ b/.changeset/fix-asset-naming.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +ci(release): use friendly asset names (linux-amd64, macos-arm64, etc.) instead of raw Rust target triples. Add asset_name field to build matrix. Update Homebrew formula URLs and FORK.md install commands accordingly. diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index a5b5e9b0..43e454b9 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -28,17 +28,29 @@ jobs: include: - os: macos-latest target: aarch64-apple-darwin + asset_name: macos-arm64 - os: macos-latest target: x86_64-apple-darwin + asset_name: macos-amd64 - os: ubuntu-latest target: x86_64-unknown-linux-gnu + asset_name: linux-amd64 - os: ubuntu-latest target: aarch64-unknown-linux-gnu + asset_name: linux-arm64 - os: windows-latest target: x86_64-pc-windows-msvc + asset_name: windows-amd64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set asset filename + shell: bash + run: | + TAG="${{ github.event.inputs.tag_name || github.ref_name }}" + VERSION="${TAG#fork/v}" + echo "ASSET=gws-mcp-${VERSION}-${{ matrix.asset_name }}" >> "$GITHUB_ENV" + - name: Install Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: @@ -87,12 +99,12 @@ jobs: - name: Package (Unix) if: runner.os != 'Windows' run: | - tar -czf "gws-${{ matrix.target }}.tar.gz" \ + tar -czf "${{ env.ASSET }}.tar.gz" \ -C "target/${{ matrix.target }}/release" gws if command -v sha256sum >/dev/null 2>&1; then - sha256sum "gws-${{ matrix.target }}.tar.gz" > "gws-${{ matrix.target }}.tar.gz.sha256" + sha256sum "${{ env.ASSET }}.tar.gz" > "${{ env.ASSET }}.tar.gz.sha256" else - shasum -a 256 "gws-${{ matrix.target }}.tar.gz" > "gws-${{ matrix.target }}.tar.gz.sha256" + shasum -a 256 "${{ env.ASSET }}.tar.gz" > "${{ env.ASSET }}.tar.gz.sha256" fi - name: Package (Windows) @@ -100,9 +112,9 @@ jobs: shell: pwsh run: | Compress-Archive -Path "target/${{ matrix.target }}/release/gws.exe" ` - -DestinationPath "gws-${{ matrix.target }}.zip" - $hash = (Get-FileHash "gws-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower() - "$hash gws-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "gws-${{ matrix.target }}.zip.sha256" + -DestinationPath "${{ env.ASSET }}.zip" + $hash = (Get-FileHash "${{ env.ASSET }}.zip" -Algorithm SHA256).Hash.ToLower() + "$hash ${{ env.ASSET }}.zip" | Out-File -Encoding ASCII "${{ env.ASSET }}.zip.sha256" - name: Generate .deb if: runner.os == 'Linux' @@ -111,8 +123,8 @@ jobs: cargo deb --no-build --no-strip --target ${{ matrix.target }} \ -p google-workspace-cli DEB=$(ls target/${{ matrix.target }}/debian/*.deb) - cp "$DEB" "gws-${{ matrix.target }}.deb" - sha256sum "gws-${{ matrix.target }}.deb" > "gws-${{ matrix.target }}.deb.sha256" + cp "$DEB" "${{ env.ASSET }}.deb" + sha256sum "${{ env.ASSET }}.deb" > "${{ env.ASSET }}.deb.sha256" - name: Generate .rpm if: runner.os == 'Linux' @@ -121,14 +133,14 @@ jobs: cargo generate-rpm --target ${{ matrix.target }} \ -p crates/google-workspace-cli RPM=$(ls target/${{ matrix.target }}/generate-rpm/*.rpm) - cp "$RPM" "gws-${{ matrix.target }}.rpm" - sha256sum "gws-${{ matrix.target }}.rpm" > "gws-${{ matrix.target }}.rpm.sha256" + cp "$RPM" "${{ env.ASSET }}.rpm" + sha256sum "${{ env.ASSET }}.rpm" > "${{ env.ASSET }}.rpm.sha256" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: gws-${{ matrix.target }} - path: gws-${{ matrix.target }}.* + name: ${{ env.ASSET }} + path: ${{ env.ASSET }}.* retention-days: 1 release: @@ -163,16 +175,20 @@ jobs: ### Debian / Ubuntu (.deb) ```bash - sudo dpkg -i gws-mcp__amd64.deb + sudo dpkg -i gws-mcp-VERSION-linux-amd64.deb + # or for arm64: + sudo dpkg -i gws-mcp-VERSION-linux-arm64.deb ``` ### RHEL / Fedora / Amazon Linux (.rpm) ```bash - sudo rpm -i gws-mcp--1.x86_64.rpm + sudo rpm -i gws-mcp-VERSION-linux-amd64.rpm + # or for aarch64: + sudo rpm -i gws-mcp-VERSION-linux-arm64.rpm ``` ### Windows - Download `gws-x86_64-pc-windows-msvc.zip`, extract `gws.exe`, and place it in a directory on your PATH. + Download `gws-mcp-VERSION-windows-amd64.zip`, extract `gws.exe`, and place it in a directory on your PATH. ### Cargo ```bash @@ -182,10 +198,12 @@ jobs: ### Direct download (macOS / Linux) Download the `.tar.gz` archive for your platform below and place `gws` in your PATH. RELNOTES + # Replace VERSION placeholder with actual version + sed -i "s/VERSION/${VERSION}/g" /tmp/release-notes.md gh release create "$TAG" \ --title "fork/v${VERSION}" \ --notes-file /tmp/release-notes.md \ - gws-* + gws-mcp-* - name: Update Homebrew formula env: @@ -197,10 +215,10 @@ jobs: TAG_ENCODED="${TAG/\//%2F}" BASE_URL="https://github.com/shigechika/gws-mcp/releases/download/${TAG_ENCODED}" - SHA_ARM64_DARWIN=$(awk '{print $1}' gws-aarch64-apple-darwin.tar.gz.sha256) - SHA_X86_64_DARWIN=$(awk '{print $1}' gws-x86_64-apple-darwin.tar.gz.sha256) - SHA_X86_64_LINUX=$(awk '{print $1}' gws-x86_64-unknown-linux-gnu.tar.gz.sha256) - SHA_ARM64_LINUX=$(awk '{print $1}' gws-aarch64-unknown-linux-gnu.tar.gz.sha256) + SHA_ARM64_DARWIN=$(awk '{print $1}' "gws-mcp-${VERSION}-macos-arm64.tar.gz.sha256") + SHA_X86_64_DARWIN=$(awk '{print $1}' "gws-mcp-${VERSION}-macos-amd64.tar.gz.sha256") + SHA_X86_64_LINUX=$(awk '{print $1}' "gws-mcp-${VERSION}-linux-amd64.tar.gz.sha256") + SHA_ARM64_LINUX=$(awk '{print $1}' "gws-mcp-${VERSION}-linux-arm64.tar.gz.sha256") cat > /tmp/gws-mcp.rb << FORMULA class GwsMcp < Formula @@ -211,22 +229,22 @@ jobs: on_macos do on_arm do - url "${BASE_URL}/gws-aarch64-apple-darwin.tar.gz" + url "${BASE_URL}/gws-mcp-${VERSION}-macos-arm64.tar.gz" sha256 "${SHA_ARM64_DARWIN}" end on_intel do - url "${BASE_URL}/gws-x86_64-apple-darwin.tar.gz" + url "${BASE_URL}/gws-mcp-${VERSION}-macos-amd64.tar.gz" sha256 "${SHA_X86_64_DARWIN}" end end on_linux do on_arm do - url "${BASE_URL}/gws-aarch64-unknown-linux-gnu.tar.gz" + url "${BASE_URL}/gws-mcp-${VERSION}-linux-arm64.tar.gz" sha256 "${SHA_ARM64_LINUX}" end on_intel do - url "${BASE_URL}/gws-x86_64-unknown-linux-gnu.tar.gz" + url "${BASE_URL}/gws-mcp-${VERSION}-linux-amd64.tar.gz" sha256 "${SHA_X86_64_LINUX}" end end diff --git a/FORK.md b/FORK.md index 738fc1b7..3514cf9b 100644 --- a/FORK.md +++ b/FORK.md @@ -51,9 +51,9 @@ No Rust toolchain required. Binaries are pre-built for macOS (Apple Silicon / In ### Debian / Ubuntu (.deb) ```bash -sudo dpkg -i gws-mcp_-1_amd64.deb +sudo dpkg -i gws-mcp--linux-amd64.deb # or for arm64: -sudo dpkg -i gws-mcp_-1_arm64.deb +sudo dpkg -i gws-mcp--linux-arm64.deb ``` Download the `.deb` file from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest). @@ -61,16 +61,16 @@ Download the `.deb` file from the [latest release](https://github.com/shigechika ### RHEL / Fedora / Amazon Linux (.rpm) ```bash -sudo rpm -i gws-mcp--1.x86_64.rpm +sudo rpm -i gws-mcp--linux-amd64.rpm # or for aarch64: -sudo rpm -i gws-mcp--1.aarch64.rpm +sudo rpm -i gws-mcp--linux-arm64.rpm ``` Download the `.rpm` file from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest). ### Windows -Download `gws-x86_64-pc-windows-msvc.zip` from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest), extract `gws.exe`, and place it in a directory on your `PATH`. +Download `gws-mcp--windows-amd64.zip` from the [latest release](https://github.com/shigechika/gws-mcp/releases/latest), extract `gws.exe`, and place it in a directory on your `PATH`. ### Direct download (macOS / Linux) @@ -78,10 +78,10 @@ Download the `.tar.gz` archive for your platform from the [latest release](https | Platform | Archive | |---|---| -| macOS (Apple Silicon) | `gws-aarch64-apple-darwin.tar.gz` | -| macOS (Intel) | `gws-x86_64-apple-darwin.tar.gz` | -| Linux x86\_64 | `gws-x86_64-unknown-linux-gnu.tar.gz` | -| Linux arm64 | `gws-aarch64-unknown-linux-gnu.tar.gz` | +| macOS (Apple Silicon) | `gws-mcp--macos-arm64.tar.gz` | +| macOS (Intel) | `gws-mcp--macos-amd64.tar.gz` | +| Linux x86\_64 | `gws-mcp--linux-amd64.tar.gz` | +| Linux arm64 | `gws-mcp--linux-arm64.tar.gz` | ### Cargo (from source) From 36da9fc308089d16a4cfbd98b390e98f5fc9487e Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 15:21:11 +0900 Subject: [PATCH 43/44] feat(mcp): Streamable HTTP transport via rmcp (Phase 1, no auth) (#15) * feat(mcp): add Streamable HTTP transport via rmcp (Phase 1, no auth) * chore: update Cargo.lock with axum and rmcp dependencies * fix(mcp): bind to 127.0.0.1 by default; add --bind flag and tool deserialization warning --- .changeset/mcp-http-transport-phase1.md | 5 + Cargo.lock | 272 +++++++++++++++++- FORK.md | 22 ++ crates/google-workspace-cli/Cargo.toml | 2 + crates/google-workspace-cli/src/main.rs | 1 + .../src/mcp_http_server.rs | 149 ++++++++++ crates/google-workspace-cli/src/mcp_server.rs | 49 +++- 7 files changed, 492 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-http-transport-phase1.md create mode 100644 crates/google-workspace-cli/src/mcp_http_server.rs diff --git a/.changeset/mcp-http-transport-phase1.md b/.changeset/mcp-http-transport-phase1.md new file mode 100644 index 00000000..0e62ceee --- /dev/null +++ b/.changeset/mcp-http-transport-phase1.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +feat(mcp): add Streamable HTTP transport (Phase 1, no auth). Start with `gws mcp -s gmail --transport http --port 3000`; Claude Desktop config changes to `{"url": "http://localhost:3000/mcp"}`. Uses rmcp 1.x `transport-streamable-http-server` feature via axum. Stdio transport unchanged. diff --git a/Cargo.lock b/Cargo.lock index b47d5a5c..e45ce908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -149,6 +149,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -246,6 +298,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -255,6 +318,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -383,6 +447,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -650,6 +723,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -753,6 +832,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -760,6 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -814,6 +909,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -880,6 +976,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -917,6 +1014,7 @@ dependencies = [ "aes-gcm", "anyhow", "async-trait", + "axum", "base64", "bytes", "chrono", @@ -937,6 +1035,7 @@ dependencies = [ "rand 0.8.6", "ratatui", "reqwest", + "rmcp", "serde", "serde_json", "serial_test", @@ -1502,6 +1601,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1688,6 +1793,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1822,7 +1933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1973,6 +2084,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2011,6 +2133,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -2116,6 +2244,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -2200,6 +2348,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.1", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2305,6 +2497,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2395,6 +2613,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -2408,6 +2637,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2468,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2546,6 +2786,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2860,6 +3113,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2927,6 +3191,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2965,6 +3230,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/FORK.md b/FORK.md index 3514cf9b..e130f2a1 100644 --- a/FORK.md +++ b/FORK.md @@ -127,6 +127,28 @@ This installs the binary to `~/.cargo/bin/gws`. Note that `cargo build --release } ``` +### HTTP transport (Streamable HTTP) + +Start the server first: + +```bash +gws mcp -s gmail -s drive -s calendar --helpers --transport http --port 3000 +``` + +Then point Claude at it — no `command`/`args` needed, just a URL: + +```json +{ + "mcpServers": { + "gws": { + "url": "http://localhost:3000/mcp" + } + } +} +``` + +The server binds to `127.0.0.1` by default (loopback only). Use `--bind 0.0.0.0` to allow external access (not recommended without additional auth). + ## Upstream MCP issues addressed in this fork Bug reports and feature requests that targeted upstream's MCP server (closed when MCP was removed). This fork ports the fixes so they remain useful: diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index bbaf632d..f80bddf6 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -66,6 +66,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" uuid = { version = "1.22.0", features = ["v4", "v5"] } mime_guess2 = "2.3.1" +axum = "0.8" +rmcp = { version = "1", features = ["transport-streamable-http-server", "server"] } [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3.6.3", features = ["apple-native"] } diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 57508a23..6722546b 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -32,6 +32,7 @@ mod fs_util; mod generate_skills; mod helpers; mod logging; +mod mcp_http_server; mod mcp_server; mod oauth_config; mod output; diff --git a/crates/google-workspace-cli/src/mcp_http_server.rs b/crates/google-workspace-cli/src/mcp_http_server.rs new file mode 100644 index 00000000..1d895510 --- /dev/null +++ b/crates/google-workspace-cli/src/mcp_http_server.rs @@ -0,0 +1,149 @@ +// 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. + +//! MCP Streamable HTTP transport (Phase 1 — no authentication). +//! +//! Serves the same tool set as the stdio transport over HTTP on `POST /mcp`. +//! Start with: `gws mcp -s gmail --transport http --port 3000` + +use std::sync::Arc; + +use rmcp::transport::{ + streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig, + StreamableHttpService, +}; +use rmcp::{ + model::{ + CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, + PaginatedRequestParams, ServerInfo, Tool, + }, + service::RequestContext, + ErrorData as McpError, RoleServer, ServerHandler, +}; +use serde_json::json; +use tokio::sync::Mutex; + +use crate::{ + error::GwsError, + mcp_server::{build_tools_list, handle_tools_call, ServerConfig}, +}; + +struct GwsMcpHandler { + config: Arc, + tools_cache: Arc>>>, +} + +impl ServerHandler for GwsMcpHandler { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.server_info = Implementation::new("gws-mcp", env!("CARGO_PKG_VERSION")); + info.capabilities.tools = Some(Default::default()); + info + } + + async fn list_tools( + &self, + _request: Option, + _ctx: RequestContext, + ) -> Result { + let mut cache = self.tools_cache.lock().await; + if cache.is_none() { + let values = build_tools_list(&self.config) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + let tools: Vec = values + .into_iter() + .filter_map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| { + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + eprintln!("[gws mcp] Warning: skipping tool '{name}': {e}"); + }) + .ok() + }) + .collect(); + *cache = Some(tools); + } + Ok(ListToolsResult { + tools: cache.as_ref().unwrap().clone(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _ctx: RequestContext, + ) -> Result { + let params = json!({ + "name": request.name.as_ref(), + "arguments": request.arguments.unwrap_or_default() + }); + let result = handle_tools_call(¶ms, &self.config) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + serde_json::from_value(result).map_err(|e| McpError::internal_error(e.to_string(), None)) + } +} + +pub(crate) async fn start_http( + config: ServerConfig, + port: u16, + bind: String, +) -> Result<(), GwsError> { + let config = Arc::new(config); + let tools_cache: Arc>>> = Arc::new(Mutex::new(None)); + + // rmcp default allowed_hosts already restricts to localhost/127.0.0.1/::1. + // For external bind addresses the caller must use --bind 0.0.0.0 explicitly, + // so we widen allowed_hosts only when the bind address is not loopback. + let loopback = matches!(bind.as_str(), "127.0.0.1" | "::1" | "localhost"); + let http_config = if loopback { + StreamableHttpServerConfig::default() + } else { + StreamableHttpServerConfig::default().disable_allowed_hosts() + }; + + let service: StreamableHttpService = { + let config = config.clone(); + let tools_cache = tools_cache.clone(); + StreamableHttpService::new( + move || { + Ok(GwsMcpHandler { + config: config.clone(), + tools_cache: tools_cache.clone(), + }) + }, + Default::default(), + http_config, + ) + }; + + let app = axum::Router::new().nest_service("/mcp", service); + let addr = format!("{bind}:{port}"); + eprintln!("[gws mcp] HTTP server listening on http://{bind}:{port}/mcp"); + if !loopback { + eprintln!("[gws mcp] Warning: server is accessible from external hosts (--bind {bind})"); + } + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to bind to {addr}: {e}")))?; + + axum::serve(listener, app) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP server error: {e}")))?; + + Ok(()) +} diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs index 911c9b5f..a92d17a0 100644 --- a/crates/google-workspace-cli/src/mcp_server.rs +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -24,13 +24,13 @@ use std::collections::HashMap; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[derive(Debug, Clone, Copy, PartialEq)] -enum ToolMode { +pub(crate) enum ToolMode { Full, Compact, } #[derive(Debug, Clone)] -struct ServerConfig { +pub(crate) struct ServerConfig { services: Vec, workflows: bool, helpers: bool, @@ -39,7 +39,28 @@ struct ServerConfig { fn build_mcp_cli() -> Command { Command::new("mcp") - .about("Starts the MCP server over stdio") + .about("Starts the MCP server (stdio by default, or HTTP with --transport http)") + .arg( + Arg::new("transport") + .long("transport") + .value_parser(["stdio", "http"]) + .default_value("stdio") + .help("Transport mode: 'stdio' (default) or 'http' (Streamable HTTP)"), + ) + .arg( + Arg::new("port") + .long("port") + .short('p') + .value_parser(clap::value_parser!(u16)) + .default_value("3000") + .help("Port to listen on (HTTP transport only)"), + ) + .arg( + Arg::new("bind") + .long("bind") + .default_value("127.0.0.1") + .help("Address to bind (HTTP transport only). Use 0.0.0.0 to allow external access"), + ) .arg( Arg::new("services") .long("services") @@ -107,6 +128,21 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> { eprintln!("[gws mcp] Tool mode: {:?}", config.tool_mode); } + let transport = matches + .get_one::("transport") + .map(|s| s.as_str()) + .unwrap_or("stdio"); + + if transport == "http" { + let port = *matches.get_one::("port").unwrap_or(&3000); + let bind = matches + .get_one::("bind") + .map(|s| s.as_str()) + .unwrap_or("127.0.0.1") + .to_string(); + return crate::mcp_http_server::start_http(config, port, bind).await; + } + let mut stdin = BufReader::new(tokio::io::stdin()).lines(); let mut stdout = tokio::io::stdout(); @@ -221,7 +257,7 @@ async fn handle_request( } } -async fn build_tools_list(config: &ServerConfig) -> Result, GwsError> { +pub(crate) async fn build_tools_list(config: &ServerConfig) -> Result, GwsError> { if config.tool_mode == ToolMode::Compact { return build_compact_tools_list(config).await; } @@ -889,7 +925,10 @@ fn find_resource<'a>( Some(current_res) } -async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result { +pub(crate) async fn handle_tools_call( + params: &Value, + config: &ServerConfig, +) -> Result { let tool_name = params .get("name") .and_then(|n| n.as_str()) From b37314b42c647ca8ed928c98b8322befeab2a805 Mon Sep 17 00:00:00 2001 From: AIKAWA Shigechika Date: Sat, 9 May 2026 17:34:34 +0900 Subject: [PATCH 44/44] feat(mcp): add OAuth2 PKCE Authorization Server for HTTP transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MCP Authorization spec 2025-11-25 on the HTTP transport. Enable with `--auth` flag alongside `--transport http`. - RFC 9728 /.well-known/oauth-protected-resource - RFC 8414 /.well-known/oauth-authorization-server - DCR stub (/oauth/register) - PKCE S256 authorization flow via Google OAuth2 (/oauth/authorize, /oauth/callback) - Token endpoint with PKCE S256 verification (/oauth/token) - Bearer auth middleware: all /mcp requests require Authorization: Bearer - Session TTL: 8 hours; pending auth: 10 minutes - --auth flag preserved as opt-in; Phase 1 (no auth) unchanged - RFC 7636 §4.6 PKCE unit tests --- .changeset/mcp-http-phase2-oauth.md | 5 + FORK.md | 29 + .../src/mcp_http_server.rs | 559 +++++++++++++++++- crates/google-workspace-cli/src/mcp_server.rs | 9 +- 4 files changed, 589 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-http-phase2-oauth.md diff --git a/.changeset/mcp-http-phase2-oauth.md b/.changeset/mcp-http-phase2-oauth.md new file mode 100644 index 00000000..3cbaa8a7 --- /dev/null +++ b/.changeset/mcp-http-phase2-oauth.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +feat(mcp): add OAuth2 PKCE Authorization Server for HTTP transport (Phase 2/3). Enable with `gws mcp --transport http --auth`. Implements MCP Authorization spec 2025-11-25: RFC 9728 protected-resource metadata, RFC 8414 AS metadata, DCR stub, PKCE S256 authorization/token flow via Google OAuth2. All `/mcp` requests require `Authorization: Bearer` when `--auth` is active; unauthenticated requests receive 401 with `WWW-Authenticate` pointing to the AS metadata URL. Sessions expire after 8 hours. Requires `client_secret.json` from `gws auth setup`. diff --git a/FORK.md b/FORK.md index e130f2a1..dbbe2265 100644 --- a/FORK.md +++ b/FORK.md @@ -12,6 +12,8 @@ It maintains the **MCP (Model Context Protocol) server** that upstream removed, |---|---|---| | MCP server (`gws mcp`) | Removed | Maintained | | MCP helper tools (`--helpers`) | N/A | `gmail_send` and more | +| HTTP transport (`--transport http`) | N/A | Streamable HTTP (Phase 1: no auth) | +| OAuth2 PKCE auth (`--auth`) | N/A | MCP spec 2025-11-25 compliant AS (RFC 9728 + RFC 8414 + PKCE S256) | | CI/CD workflows | Upstream-specific | Minimal (CI + Policy + Sync + Release) | ### MCP server @@ -149,6 +151,33 @@ Then point Claude at it — no `command`/`args` needed, just a URL: The server binds to `127.0.0.1` by default (loopback only). Use `--bind 0.0.0.0` to allow external access (not recommended without additional auth). +### OAuth2 PKCE authentication (`--auth`) + +Enables a full OAuth2 Authorization Server on the HTTP transport, compliant with the [MCP Authorization spec 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/). + +**Prerequisites:** +1. Run `gws auth setup` to create `client_secret.json` with a Google OAuth2 web app credential +2. Add `http://localhost:/oauth/callback` as an **Authorized redirect URI** in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) + +```bash +gws mcp -s gmail -s drive -s calendar --helpers --transport http --port 3000 --auth +``` + +The server exposes these OAuth2 endpoints: + +| Endpoint | RFC | Purpose | +|---|---|---| +| `/.well-known/oauth-protected-resource` | RFC 9728 | Protected Resource Metadata | +| `/.well-known/oauth-authorization-server` | RFC 8414 | Authorization Server Metadata | +| `/oauth/register` | RFC 7591 (stub) | Dynamic Client Registration | +| `/oauth/authorize` | RFC 6749 | Authorization endpoint — redirects to Google | +| `/oauth/callback` | — | Google OAuth2 callback | +| `/oauth/token` | RFC 6749 | Token endpoint — exchanges code + PKCE verifier for bearer token | + +All requests to `/mcp` require a valid `Authorization: Bearer ` header. Sessions expire after 8 hours. + +> **Note:** In Phase 2/3 (current), GWS API calls still use the shared `gws auth login` token. Bearer tokens identify the user (email) but do not yet carry per-user GWS credentials. Per-user token isolation is planned for Phase 4. + ## Upstream MCP issues addressed in this fork Bug reports and feature requests that targeted upstream's MCP server (closed when MCP was removed). This fork ports the fixes so they remain useful: diff --git a/crates/google-workspace-cli/src/mcp_http_server.rs b/crates/google-workspace-cli/src/mcp_http_server.rs index 1d895510..ecd448d8 100644 --- a/crates/google-workspace-cli/src/mcp_http_server.rs +++ b/crates/google-workspace-cli/src/mcp_http_server.rs @@ -12,13 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! MCP Streamable HTTP transport (Phase 1 — no authentication). +//! MCP Streamable HTTP transport with optional OAuth2 PKCE authentication. //! -//! Serves the same tool set as the stdio transport over HTTP on `POST /mcp`. -//! Start with: `gws mcp -s gmail --transport http --port 3000` +//! Phase 1 (no auth): `gws mcp -s gmail --transport http --port 3000` +//! Phase 2 (OAuth2): `gws mcp -s gmail --transport http --port 3000 --auth` +//! +//! When `--auth` is enabled the server implements the MCP Authorization spec +//! (2025-11-25, RFC 9728 / RFC 8414 / PKCE-S256) and acts as both OAuth2 +//! Authorization Server and Resource Server. Google is used as the identity +//! provider; a `client_secret.json` created via `gws auth setup` is required. +//! +//! Phase 2+3 limitation: Google API calls still use the shared credential from +//! `gws auth login`. Per-user token isolation is Phase 4. -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime}, +}; +use axum::{ + extract::{Query, State}, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Form, Json, Router, +}; use rmcp::transport::{ streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, @@ -31,14 +51,25 @@ use rmcp::{ service::RequestContext, ErrorData as McpError, RoleServer, ServerHandler, }; -use serde_json::json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use tokio::sync::Mutex; +use uuid::Uuid; use crate::{ error::GwsError, mcp_server::{build_tools_list, handle_tools_call, ServerConfig}, + oauth_config::{load_client_config, InstalledConfig}, }; +// ─── Constants ─────────────────────────────────────────────────────────────── + +const SESSION_TTL: Duration = Duration::from_secs(8 * 3600); // 8 h +const PENDING_TTL: Duration = Duration::from_secs(600); // 10 min + +// ─── Phase 1: MCP handler ──────────────────────────────────────────────────── + struct GwsMcpHandler { config: Arc, tools_cache: Arc>>>, @@ -97,17 +128,479 @@ impl ServerHandler for GwsMcpHandler { } } +// ─── Phase 2: Auth types ───────────────────────────────────────────────────── + +/// State stored between `/oauth/authorize` and `/oauth/callback`. +struct PendingAuth { + code_challenge: String, + #[allow(dead_code)] // kept for future client_id validation + client_id: String, + redirect_uri: String, + created_at: SystemTime, +} + +/// State stored between `/oauth/callback` and `/oauth/token`. +struct PendingCode { + code_challenge: String, + client_redirect_uri: String, + email: String, + created_at: SystemTime, +} + +struct UserSession { + #[allow(dead_code)] // used in Phase 4 for per-user token lookup + email: String, + #[allow(dead_code)] + created_at: SystemTime, +} + +#[derive(Default)] +struct AuthStore { + /// OAuth `state` value → PendingAuth (before Google callback) + pending: Mutex>, + /// Short-lived auth code → PendingCode (after Google callback, before token exchange) + codes: Mutex>, + /// Bearer UUID → UserSession (live sessions) + sessions: Mutex>, +} + +#[derive(Clone)] +struct AppState { + auth_store: Arc, + oauth_cfg: Arc, + port: u16, + bind: String, +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn is_expired(created_at: SystemTime, ttl: Duration) -> bool { + SystemTime::now() + .duration_since(created_at) + .map(|d| d >= ttl) + .unwrap_or(true) +} + +fn base64url(bytes: &[u8]) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD.encode(bytes) +} + +fn verify_pkce_s256(verifier: &str, challenge: &str) -> bool { + base64url(&Sha256::digest(verifier.as_bytes())) == challenge +} + +fn urlencode(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() +} + +/// Returns the public base URL of the server (uses `localhost` even when bound to `0.0.0.0`). +fn server_base(port: u16, bind: &str) -> String { + let host = match bind { + "0.0.0.0" => "localhost", + "::" => "::1", + other => other, + }; + format!("http://{host}:{port}") +} + +fn build_google_auth_url( + client_id: &str, + redirect_uri: &str, + scopes: &str, + state: &str, +) -> String { + format!( + "https://accounts.google.com/o/oauth2/auth?\ + scope={}&access_type=offline&redirect_uri={}&\ + response_type=code&client_id={}&state={}&\ + prompt=select_account+consent", + urlencode(scopes), + urlencode(redirect_uri), + urlencode(client_id), + urlencode(state), + ) +} + +// ─── Phase 3: Bearer middleware ─────────────────────────────────────────────── + +async fn bearer_auth_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + // OAuth and well-known endpoints are public + if path.starts_with("/oauth/") || path.starts_with("/.well-known/") { + return next.run(request).await; + } + + let Some(token) = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.to_string()) + else { + return ( + StatusCode::UNAUTHORIZED, + [( + "WWW-Authenticate", + "Bearer realm=\"gws-mcp\", resource_metadata=\"/.well-known/oauth-protected-resource\"", + )], + ) + .into_response(); + }; + + let sessions = state.auth_store.sessions.lock().await; + match sessions.get(&token) { + Some(s) if !is_expired(s.created_at, SESSION_TTL) => {} + _ => { + return ( + StatusCode::UNAUTHORIZED, + [( + "WWW-Authenticate", + "Bearer realm=\"gws-mcp\", error=\"invalid_token\", resource_metadata=\"/.well-known/oauth-protected-resource\"", + )], + ) + .into_response(); + } + } + + next.run(request).await +} + +// ─── OAuth metadata endpoints ──────────────────────────────────────────────── + +/// RFC 9728 — OAuth 2.0 Protected Resource Metadata +async fn protected_resource_metadata(State(state): State) -> Json { + let base = server_base(state.port, &state.bind); + Json(json!({ + "resource": base, + "authorization_servers": [base], + "bearer_methods_supported": ["header"], + "resource_documentation": "https://github.com/shigechika/gws-mcp" + })) +} + +/// RFC 8414 — OAuth 2.0 Authorization Server Metadata +async fn authorization_server_metadata(State(state): State) -> Json { + let base = server_base(state.port, &state.bind); + Json(json!({ + "issuer": base, + "authorization_endpoint": format!("{base}/oauth/authorize"), + "token_endpoint": format!("{base}/oauth/token"), + "registration_endpoint": format!("{base}/oauth/register"), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["openid", "email", "profile"] + })) +} + +// ─── Dynamic Client Registration stub (RFC 7591) ───────────────────────────── + +#[derive(Deserialize)] +struct RegisterRequest { + client_name: Option, + redirect_uris: Option>, +} + +async fn oauth_register( + State(_): State, + Json(req): Json, +) -> impl IntoResponse { + let client_id = req + .client_name + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "gws-mcp-client".to_string()); + ( + StatusCode::CREATED, + Json(json!({ + "client_id": client_id, + "redirect_uris": req.redirect_uris.unwrap_or_default(), + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + })), + ) +} + +// ─── /oauth/authorize ──────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct AuthorizeParams { + response_type: String, + client_id: String, + redirect_uri: String, + state: String, + code_challenge: String, + code_challenge_method: String, + scope: Option, +} + +async fn oauth_authorize( + State(state): State, + Query(p): Query, +) -> Result { + if p.response_type != "code" { + return Err(( + StatusCode::BAD_REQUEST, + "unsupported_response_type".to_string(), + )); + } + if p.code_challenge_method != "S256" { + return Err(( + StatusCode::BAD_REQUEST, + "only S256 code_challenge_method is supported".to_string(), + )); + } + + state.auth_store.pending.lock().await.insert( + p.state.clone(), + PendingAuth { + code_challenge: p.code_challenge, + client_id: p.client_id, + redirect_uri: p.redirect_uri, + created_at: SystemTime::now(), + }, + ); + + let callback = format!("{}/oauth/callback", server_base(state.port, &state.bind)); + // Request openid/email/profile from Google to identify the user. + // GWS API scopes are not needed here because Phase 2+3 still uses the + // shared credential from `gws auth login`. Per-user GWS tokens are Phase 4. + let scopes = p.scope.as_deref().unwrap_or("openid email profile"); + let google_url = + build_google_auth_url(&state.oauth_cfg.client_id, &callback, scopes, &p.state); + + Ok(Redirect::to(&google_url)) +} + +// ─── /oauth/callback ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct CallbackParams { + code: Option, + state: String, + error: Option, +} + +async fn oauth_callback( + State(state): State, + Query(p): Query, +) -> Result { + if let Some(err) = p.error { + return Err(( + StatusCode::BAD_REQUEST, + format!("Google OAuth error: {err}"), + )); + } + let google_code = p + .code + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing code".to_string()))?; + + let pending = { + let mut lock = state.auth_store.pending.lock().await; + lock.remove(&p.state) + .filter(|pa| !is_expired(pa.created_at, PENDING_TTL)) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "invalid or expired state".to_string()))? + }; + + let callback = format!("{}/oauth/callback", server_base(state.port, &state.bind)); + let token_resp = + exchange_google_code(&state.oauth_cfg, &google_code, &callback) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let email = get_google_email(&token_resp.access_token) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let auth_code = Uuid::new_v4().to_string(); + state.auth_store.codes.lock().await.insert( + auth_code.clone(), + PendingCode { + code_challenge: pending.code_challenge, + client_redirect_uri: pending.redirect_uri.clone(), + email, + created_at: SystemTime::now(), + }, + ); + + let location = format!( + "{}?code={}&state={}", + pending.redirect_uri, + urlencode(&auth_code), + urlencode(&p.state), + ); + Ok(Redirect::to(&location)) +} + +// ─── /oauth/token ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct TokenRequest { + grant_type: String, + code: String, + code_verifier: String, + redirect_uri: Option, +} + +#[derive(Serialize)] +struct TokenResponse { + access_token: String, + token_type: &'static str, + expires_in: u64, +} + +async fn oauth_token( + State(state): State, + Form(req): Form, +) -> Result, (StatusCode, Json)> { + if req.grant_type != "authorization_code" { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "unsupported_grant_type"})), + )); + } + + let pending_code = { + let mut lock = state.auth_store.codes.lock().await; + lock.remove(&req.code) + .filter(|pc| !is_expired(pc.created_at, PENDING_TTL)) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_grant"})), + ) + })? + }; + + // Validate redirect_uri if provided + if let Some(uri) = &req.redirect_uri { + if *uri != pending_code.client_redirect_uri { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_grant", "error_description": "redirect_uri mismatch"})), + )); + } + } + + // Verify PKCE S256 + if !verify_pkce_s256(&req.code_verifier, &pending_code.code_challenge) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_grant", "error_description": "PKCE verification failed"})), + )); + } + + let bearer = Uuid::new_v4().to_string(); + state.auth_store.sessions.lock().await.insert( + bearer.clone(), + UserSession { + email: pending_code.email.clone(), + created_at: SystemTime::now(), + }, + ); + + eprintln!("[gws mcp] Session created for {}", pending_code.email); + + Ok(Json(TokenResponse { + access_token: bearer, + token_type: "bearer", + expires_in: SESSION_TTL.as_secs(), + })) +} + +// ─── Google helpers ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct GoogleTokenResp { + access_token: String, +} + +async fn exchange_google_code( + cfg: &InstalledConfig, + code: &str, + redirect_uri: &str, +) -> anyhow::Result { + let client = crate::client::shared_client()?; + let params = [ + ("client_id", cfg.client_id.as_str()), + ("client_secret", cfg.client_secret.as_str()), + ("code", code), + ("redirect_uri", redirect_uri), + ("grant_type", "authorization_code"), + ]; + let resp = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = crate::auth::response_text_or_placeholder(resp.text().await); + anyhow::bail!("Google token exchange failed ({status}): {body}"); + } + resp.json().await.map_err(Into::into) +} + +async fn get_google_email(access_token: &str) -> anyhow::Result { + #[derive(Deserialize)] + struct UserinfoResp { + email: String, + } + let client = crate::client::shared_client()?; + let resp: UserinfoResp = client + .get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(access_token) + .send() + .await? + .json() + .await?; + Ok(resp.email) +} + +// ─── Router builder ─────────────────────────────────────────────────────────── + +fn build_auth_router( + mcp_service: StreamableHttpService, + app_state: AppState, +) -> Router { + Router::new() + .route( + "/.well-known/oauth-protected-resource", + get(protected_resource_metadata), + ) + .route( + "/.well-known/oauth-authorization-server", + get(authorization_server_metadata), + ) + .route("/oauth/register", post(oauth_register)) + .route("/oauth/authorize", get(oauth_authorize)) + .route("/oauth/callback", get(oauth_callback)) + .route("/oauth/token", post(oauth_token)) + .nest_service("/mcp", mcp_service) + .layer(middleware::from_fn_with_state( + app_state.clone(), + bearer_auth_middleware, + )) + .with_state(app_state) +} + +// ─── Public entry point ─────────────────────────────────────────────────────── + pub(crate) async fn start_http( config: ServerConfig, port: u16, bind: String, + enable_auth: bool, ) -> Result<(), GwsError> { let config = Arc::new(config); let tools_cache: Arc>>> = Arc::new(Mutex::new(None)); - // rmcp default allowed_hosts already restricts to localhost/127.0.0.1/::1. - // For external bind addresses the caller must use --bind 0.0.0.0 explicitly, - // so we widen allowed_hosts only when the bind address is not loopback. let loopback = matches!(bind.as_str(), "127.0.0.1" | "::1" | "localhost"); let http_config = if loopback { StreamableHttpServerConfig::default() @@ -115,7 +608,7 @@ pub(crate) async fn start_http( StreamableHttpServerConfig::default().disable_allowed_hosts() }; - let service: StreamableHttpService = { + let mcp_service: StreamableHttpService = { let config = config.clone(); let tools_cache = tools_cache.clone(); StreamableHttpService::new( @@ -130,11 +623,34 @@ pub(crate) async fn start_http( ) }; - let app = axum::Router::new().nest_service("/mcp", service); + let app = if enable_auth { + let oauth_cfg = load_client_config().map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "--auth requires client_secret.json (run `gws auth setup` first): {e}" + )) + })?; + let app_state = AppState { + auth_store: Arc::new(AuthStore::default()), + oauth_cfg: Arc::new(oauth_cfg), + port, + bind: bind.clone(), + }; + build_auth_router(mcp_service, app_state) + } else { + Router::new().nest_service("/mcp", mcp_service) + }; + let addr = format!("{bind}:{port}"); - eprintln!("[gws mcp] HTTP server listening on http://{bind}:{port}/mcp"); + let base = server_base(port, &bind); + eprintln!("[gws mcp] HTTP server listening on {base}/mcp"); + if enable_auth { + eprintln!("[gws mcp] OAuth2 auth enabled — callback URL: {base}/oauth/callback"); + eprintln!( + "[gws mcp] Ensure {base}/oauth/callback is registered as a redirect URI in Google Cloud Console" + ); + } if !loopback { - eprintln!("[gws mcp] Warning: server is accessible from external hosts (--bind {bind})"); + eprintln!("[gws mcp] Warning: server accessible from external hosts (--bind {bind})"); } let listener = tokio::net::TcpListener::bind(&addr) @@ -147,3 +663,22 @@ pub(crate) async fn start_http( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_s256_rfc7636_example() { + // RFC 7636 §4.6 worked example + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + assert!(verify_pkce_s256(verifier, challenge)); + } + + #[test] + fn pkce_s256_rejects_wrong_verifier() { + let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + assert!(!verify_pkce_s256("wrong-verifier", challenge)); + } +} diff --git a/crates/google-workspace-cli/src/mcp_server.rs b/crates/google-workspace-cli/src/mcp_server.rs index a92d17a0..be1aa281 100644 --- a/crates/google-workspace-cli/src/mcp_server.rs +++ b/crates/google-workspace-cli/src/mcp_server.rs @@ -61,6 +61,12 @@ fn build_mcp_cli() -> Command { .default_value("127.0.0.1") .help("Address to bind (HTTP transport only). Use 0.0.0.0 to allow external access"), ) + .arg( + Arg::new("auth") + .long("auth") + .action(clap::ArgAction::SetTrue) + .help("Enable OAuth2 PKCE authentication (HTTP transport only). Requires client_secret.json from `gws auth setup`"), + ) .arg( Arg::new("services") .long("services") @@ -140,7 +146,8 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> { .map(|s| s.as_str()) .unwrap_or("127.0.0.1") .to_string(); - return crate::mcp_http_server::start_http(config, port, bind).await; + let enable_auth = matches.get_flag("auth"); + return crate::mcp_http_server::start_http(config, port, bind, enable_auth).await; } let mut stdin = BufReader::new(tokio::io::stdin()).lines();