Showing preview only (820K chars total). Download the full file or copy to clipboard to get everything.
Repository: gemini-cli-extensions/workspace
Branch: main
Commit: ad595af82240
Files: 124
Total size: 776.8 KB
Directory structure:
gitextract_ojj5tm71/
├── .gemini/
│ └── skills/
│ └── code-reviewer/
│ └── SKILL.md
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── ci.yml
│ ├── deploy-docs.yml
│ ├── release.yml
│ └── weekly-preview.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── GEMINI.md
├── LICENSE
├── README.md
├── SECURITY.md
├── cloud_function/
│ ├── index.js
│ └── package.json
├── commands/
│ ├── calendar/
│ │ ├── clear-schedule.toml
│ │ └── get-schedule.toml
│ ├── drive/
│ │ └── search.toml
│ └── gmail/
│ └── search.toml
├── docs/
│ ├── .vitepress/
│ │ └── config.mts
│ ├── GCP-RECREATION.md
│ ├── development.md
│ ├── feature-configuration.md
│ ├── index.md
│ ├── release.md
│ └── release_notes.md
├── eslint.config.js
├── gemini-extension.json
├── jest.config.js
├── package.json
├── scripts/
│ ├── auth-utils.js
│ ├── clean.js
│ ├── list-deps.js
│ ├── print-scopes.ts
│ ├── release.js
│ ├── set-version.js
│ ├── setup-gcp.sh
│ ├── start.js
│ ├── tsconfig.json
│ └── utils/
│ └── dependencies.js
├── skills/
│ ├── gmail/
│ │ └── SKILL.md
│ ├── google-calendar/
│ │ └── SKILL.md
│ ├── google-chat/
│ │ └── SKILL.md
│ ├── google-docs/
│ │ └── SKILL.md
│ ├── google-sheets/
│ │ └── SKILL.md
│ └── google-slides/
│ └── SKILL.md
├── tsconfig.json
└── workspace-server/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── WORKSPACE-Context.md
├── esbuild.auth-utils.js
├── esbuild.config.js
├── esbuild.headless-login.js
├── jest.config.js
├── package.json
├── src/
│ ├── __tests__/
│ │ ├── auth/
│ │ │ ├── AuthManager.test.ts
│ │ │ └── token-storage/
│ │ │ ├── base-token-storage.test.ts
│ │ │ ├── file-token-storage.test.ts
│ │ │ ├── hybrid-token-storage.test.ts
│ │ │ ├── keychain-token-storage.test.ts
│ │ │ └── oauth-credential-storage.test.ts
│ │ ├── features/
│ │ │ ├── feature-config.test.ts
│ │ │ └── feature-resolver.test.ts
│ │ ├── mocks/
│ │ │ └── wasm.js
│ │ ├── services/
│ │ │ ├── CalendarService.test.ts
│ │ │ ├── CalendarValidation.test.ts
│ │ │ ├── ChatService.test.ts
│ │ │ ├── DocsService.comments.test.ts
│ │ │ ├── DocsService.test.ts
│ │ │ ├── DriveService.test.ts
│ │ │ ├── GmailService.test.ts
│ │ │ ├── PeopleService.test.ts
│ │ │ ├── SheetsService.test.ts
│ │ │ ├── SlidesService.test.ts
│ │ │ └── TimeService.test.ts
│ │ ├── setup.ts
│ │ ├── tool-normalization.test.ts
│ │ └── utils/
│ │ ├── DriveQueryBuilder.test.ts
│ │ ├── IdUtils.test.ts
│ │ ├── MimeHelper.test.ts
│ │ ├── config.test.ts
│ │ ├── logger.test.ts
│ │ ├── paths.test.ts
│ │ ├── secure-browser-launcher.test.ts
│ │ └── validation.test.ts
│ ├── auth/
│ │ ├── AuthManager.ts
│ │ ├── scopes.ts
│ │ └── token-storage/
│ │ ├── base-token-storage.ts
│ │ ├── file-token-storage.ts
│ │ ├── hybrid-token-storage.ts
│ │ ├── index.ts
│ │ ├── keychain-token-storage.ts
│ │ ├── oauth-credential-storage.ts
│ │ └── types.ts
│ ├── cli/
│ │ └── headless-login.ts
│ ├── features/
│ │ ├── feature-config.ts
│ │ ├── feature-resolver.ts
│ │ └── index.ts
│ ├── index.ts
│ ├── services/
│ │ ├── CalendarService.ts
│ │ ├── CalendarValidation.ts
│ │ ├── ChatService.ts
│ │ ├── DocsService.ts
│ │ ├── DriveService.ts
│ │ ├── GmailService.ts
│ │ ├── PeopleService.ts
│ │ ├── SheetsService.ts
│ │ ├── SlidesService.ts
│ │ └── TimeService.ts
│ └── utils/
│ ├── DriveQueryBuilder.ts
│ ├── GaxiosConfig.ts
│ ├── IdUtils.ts
│ ├── MimeHelper.ts
│ ├── config.ts
│ ├── constants.ts
│ ├── logger.ts
│ ├── open-wrapper.ts
│ ├── paths.ts
│ ├── secure-browser-launcher.ts
│ ├── tool-normalization.ts
│ └── validation.ts
├── tsconfig.json
└── tsconfig.test.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gemini/skills/code-reviewer/SKILL.md
================================================
---
name: code-reviewer
description:
Use this skill to review code. It supports both local changes (staged or
working tree) and remote Pull Requests (by ID or URL). It focuses on
correctness, maintainability, and adherence to project standards.
---
# Code Reviewer
This skill guides the agent in conducting professional and thorough code reviews
for both local development and remote Pull Requests.
## Workflow
### 1. Determine Review Target
- **Remote PR**: If the user provides a PR number or URL (e.g., "Review PR
#123"), target that remote PR.
- **Local Changes**: If no specific PR is mentioned, or if the user asks to
"review my changes", target the current local file system states (staged and
unstaged changes).
### 2. Preparation
#### For Remote PRs:
1. **Checkout**: Use the GitHub CLI to checkout the PR.
```bash
gh pr checkout <PR_NUMBER>
```
2. **Verification**: Execute the workspace's verification suite to catch issues
early. Capture the output of these commands to inform your review (e.g.,
note any failed tests or linting errors).
```bash
npm install
npm run build
npm run test
npm run lint
npm run format:check
```
3. **Context**: Read the PR description and any existing comments to understand
the goal and history.
#### For Local Changes:
1. **Identify Changes**:
- Check status: `git status`
- Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged).
2. **Verification**: Ask the user if they want to run the verification suite
before reviewing. If yes, run the same commands as for remote PRs.
### 3. In-Depth Analysis
Analyze the code changes based on the following pillars:
- **Correctness**: Does the code achieve its stated purpose without bugs or
logical errors?
- **Maintainability**: Is the code clean, well-structured, and easy to
understand and modify in the future? Consider factors like code clarity,
modularity, and adherence to established design patterns.
- **Readability**: Is the code well-commented (where necessary) and consistently
formatted according to our project's coding style guidelines?
- **Efficiency**: Are there any obvious performance bottlenecks or resource
inefficiencies introduced by the changes?
- **Security**: Are there any potential security vulnerabilities or insecure
coding practices?
- **Edge Cases and Error Handling**: Does the code appropriately handle edge
cases and potential errors?
- **Testability**: Is the new or modified code adequately covered by tests (even
if preflight checks pass)? Suggest additional test cases that would improve
coverage or robustness.
### 4. Draft Feedback (DO NOT FIX)
**IMPORTANT**: You are a reviewer, NOT a fixer. Do NOT attempt to fix the code
yourself. Your goal is to provide high-quality feedback.
#### Structure
- **Summary**: A high-level overview of the review.
- **Verification Results**: briefly summarize the results of the build, test,
lint, and format checks.
- **Findings**:
- **Critical**: Bugs, security issues, test failures, lint errors, or breaking
changes.
- **Improvements**: Suggestions for better code quality or performance.
- **Nitpicks**: Formatting or minor style issues (optional).
- **Conclusion**: Clear recommendation (Approved / Request Changes).
#### Tone
- Be constructive, professional, and friendly.
- Explain _why_ a change is requested.
- For approvals, acknowledge the specific value of the contribution.
### 5. Interactive Refinement
1. **Present Draft**: Show the drafted review feedback to the user.
2. **Solicit Input**: Ask the user for their thoughts.
- "Does this feedback look accurate?"
- "Is the tone appropriate?"
- "Did I miss any context?"
3. **Iterate**: If the user provides suggestions or corrections, update the
draft feedback accordingly.
4. **Finalize**: specific approval from the user is not strictly required if
they don't have further comments, but ensure they have a chance to respond.
### 6. Cleanup (Remote PRs only)
- After the review is complete, ask the user if they want to switch back to the
default branch (e.g., `main` or `master`).
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
open-pull-requests-limit: 5
groups:
typescript:
patterns:
- 'typescript'
npm-root:
patterns:
- '*'
labels:
- 'dependencies'
- 'npm'
commit-message:
prefix: 'chore'
include: 'scope'
- package-ecosystem: 'npm'
directory: '/workspace-server'
schedule:
interval: 'weekly'
open-pull-requests-limit: 5
groups:
npm-workspace-server:
patterns:
- '*'
labels:
- 'dependencies'
- 'npm'
commit-message:
prefix: 'chore'
include: 'scope'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
open-pull-requests-limit: 5
groups:
github-actions:
patterns:
- '*'
labels:
- 'dependencies'
- 'github-actions'
commit-message:
prefix: 'chore'
include: 'scope'
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js 20.x
uses: actions/setup-node@v6
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run Prettier check
run: npm run format:check
- name: Run type checking
run: npx tsc --noEmit --project workspace-server
test:
needs: verify
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install libsecret (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-0
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
directory: ./workspace-server/coverage
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: dist
path: workspace-server/dist/
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run security audit
run: npm audit --audit-level=moderate
continue-on-error: true
- name: Check for known vulnerabilities
run: npx audit-ci --moderate
continue-on-error: true
================================================
FILE: .github/workflows/deploy-docs.yml
================================================
name: Deploy Docs to GitHub Pages
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # For actions/checkout to fetch code
pages: write # For uploading the artifact
id-token: write # For OIDC authentication
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # Not strictly needed but good practice for some build tools
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 20
cache: npm # Cache npm dependencies
- name: Install dependencies
run: npm install
- name: Build docs
run: npm run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: docs/.vitepress/dist # The directory where VitePress builds the docs
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
permissions:
pages: write
id-token: write
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux
- os: macos-latest
platform: darwin
- os: windows-latest
platform: win32
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build --workspace=workspace-server
- name: Create release assets
run: npm run release -- --platform=${{ matrix.platform }}
- name: Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')
with:
files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz
================================================
FILE: .github/workflows/weekly-preview.yml
================================================
name: Weekly Preview Release
on:
schedule:
# Every Monday at 09:00 UTC
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.date.outputs.tag_name }}
steps:
- name: Get current date
id: date
run: echo "tag_name=preview-$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
create-release:
needs: prepare
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Create Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare.outputs.tag_name }}
name: Weekly Preview ${{ needs.prepare.outputs.tag_name }}
prerelease: true
release:
needs: [prepare, create-release]
name: Build and Release
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux
- os: macos-latest
platform: darwin
- os: windows-latest
platform: win32
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build --workspace=workspace-server
- name: Create release assets
shell: bash
env:
GITHUB_REF_NAME: ${{ needs.prepare.outputs.tag_name }}
run: npm run release -- --platform=${{ matrix.platform }}
- name: Upload Release Assets
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare.outputs.tag_name }}
files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz
================================================
FILE: .gitignore
================================================
# macOS
.DS_Store
# logs
logs
# Dependencies
node_modules/
# Build outputs
dist/
# Environment files
.env
.env.local
# Logs
*.log
# Coverage
coverage/
# Editor files
*.swp
*.swo
*~
.idea/
.vscode/
# Auth tokens
token.json
gemini-cli-workspace-token.json
.gemini-cli-workspace-master-key
commit_message.txt
# Release directory
release/
# VitePress
docs/.vitepress/dist
docs/.vitepress/cache
================================================
FILE: .prettierignore
================================================
node_modules
dist
coverage
.vitepress/cache
.vitepress/dist
package-lock.json
================================================
FILE: .prettierrc.json
================================================
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"overrides": [
{
"files": ["**/*.md"],
"options": {
"proseWrap": "always"
}
}
]
}
================================================
FILE: CONTRIBUTING.md
================================================
# How to Contribute
We would love to accept your patches and contributions to this project.
## Before you begin
### Sign our Contributor License Agreement
Contributions to this project must be accompanied by a
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
You (or your employer) retain the copyright to your contribution; this simply
gives us permission to use and redistribute your contributions as part of the
project.
If you or your current employer have already signed the Google CLA (even if it
was for a different project), you probably don't need to do it again.
Visit <https://cla.developers.google.com/> to see your current agreements or to
sign a new one.
### Review our Community Guidelines
This project follows
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
## Contribution Process
### Code Reviews
All submissions, including submissions by project members, require review. We
use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)
for this purpose.
### Self Assigning Issues
If you're looking for an issue to work on, check out our list of issues that are
labeled
["help wanted"](https://github.com/gemini-cli-extensions/workspace/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22).
To assign an issue to yourself, simply add a comment with the text `/assign`.
The comment must contain only that text and nothing else. This command will
assign the issue to you, provided it is not already assigned.
Please note that you can have a maximum of 3 issues assigned to you at any given
time.
### Pull Request Guidelines
To help us review and merge your PRs quickly, please follow these guidelines.
PRs that do not meet these standards may be closed.
#### 1. Link to an Existing Issue
All PRs should be linked to an existing issue in our tracker. This ensures that
every change has been discussed and is aligned with the project's goals before
any code is written.
- **For bug fixes:** The PR should be linked to the bug report issue.
- **For features:** The PR should be linked to the feature request or proposal
issue that has been approved by a maintainer.
If an issue for your change doesn't exist, please **open one first** and wait
for feedback before you start coding.
#### 2. Keep It Small and Focused
We favor small, atomic PRs that address a single issue or add a single,
self-contained feature.
- **Do:** Create a PR that fixes one specific bug or adds one specific feature.
- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature,
and a refactor) into a single PR.
Large changes should be broken down into a series of smaller, logical PRs that
can be reviewed and merged independently.
#### 3. Use Draft PRs for Work in Progress
If you'd like to get early feedback on your work, please use GitHub's **Draft
Pull Request** feature. This signals to the maintainers that the PR is not yet
ready for a formal review but is open for discussion and initial feedback.
#### 4. Ensure All Checks Pass
Before submitting your PR, ensure that all automated checks are passing by
running:
```bash
npm run test && npm run lint && npm run format:check && npx tsc --noEmit --project workspace-server
```
This command runs all tests, linting, formatting, and type checks.
#### 5. Write Clear Commit Messages and a Good PR Description
Your PR should have a clear, descriptive title and a detailed description of the
changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/)
standard for your commit messages.
- **Good PR Title:** `feat(cli): Add --json flag to 'config get' command`
- **Bad PR Title:** `Made some changes`
In the PR description, explain the "why" behind your changes and link to the
relevant issue (e.g., `Fixes #123`).
## Development Setup and Workflow
For information on how to build, modify, and understand the development setup of
this project, please see the [development documentation](docs/development.md).
================================================
FILE: GEMINI.md
================================================
This is a Gemini extension that provides tools for interacting with Google
Workspace services like Google Docs.
### Building and Running
- **Install dependencies:** `npm install`
- **Build the project:** `npm run build --prefix workspace-server`
### Development Conventions
This project uses TypeScript and the Model Context Protocol (MCP) SDK to create
a Gemini extension. The main entry point is `src/index.ts`, which initializes
the MCP server and registers the available tools.
The business logic for each service is separated into its own file in the
`src/services` directory. For example, `src/services/DocsService.ts` contains
the logic for interacting with the Google Docs API.
Authentication is handled by the `src/auth/AuthManager.ts` file, which uses the
`@google-cloud/local-auth` library to obtain and refresh OAuth 2.0 credentials.
### Adding New Tools
To add a new tool, you need to:
1. Add a new method to the appropriate service file in `src/services`.
2. In `src/index.ts`, register the new tool with the MCP server by calling
`server.registerTool()`. You will need to provide a name for the tool, a
description, and the input schema using the `zod` library.
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# Google Workspace Extension for Gemini CLI
[](https://github.com/gemini-cli-extensions/workspace/actions/workflows/ci.yml)
The Google Workspace extension for Gemini CLI brings the power of your Google
Workspace apps to your command line. Manage your documents, spreadsheets,
presentations, emails, chat, and calendar events without leaving your terminal.
## Prerequisites
Before using the Google Workspace extension, you need to be logged into your
Google account.
## Installation
Install the Google Workspace extension by running the following command from
your terminal:
```bash
gemini extensions install https://github.com/gemini-cli-extensions/workspace
```
## Usage
Once the extension is installed, you can use it to interact with your Google
Workspace apps. Here are a few examples:
**Create a new Google Doc:**
> "Create a new Google Doc with the title 'My New Doc' and the content '# My New
> Document\n\nThis is a new document created from the command line.'"
**List your upcoming calendar events:**
> "What's on my calendar for today?"
**Search for a file in Google Drive:**
> "Find the file named 'my-file.txt' in my Google Drive."
## Commands
This extension provides a variety of commands. Here are a few examples:
### Get Schedule
**Command:** `/calendar:get-schedule [date]`
Shows your schedule for today or a specified date.
### Search Drive
**Command:** `/drive:search <query>`
Searches your Google Drive for files matching the given query.
## Headless / Remote Environments
If you're using the extension over SSH, WSL, Cloud Shell, or another environment
without a local browser, you can authenticate using the headless login tool:
```bash
npm run auth-utils -- login
```
This prints an OAuth URL you can open in any browser (local machine, phone,
etc.). After signing in, paste the credentials JSON into the CLI. Credentials
are read securely from `/dev/tty` and are never exposed to the AI model. See the
[development docs](docs/development.md#headless--remote-environments) for more
details.
## Deployment
If you want to host your own version of this extension's infrastructure, see the
[GCP Recreation Guide](docs/GCP-RECREATION.md).
## Resources
- [Documentation](docs/index.md): Detailed documentation on all the available
tools.
- [GitHub Issues](https://github.com/gemini-cli-extensions/workspace/issues):
Report bugs or request features.
## Important security consideration: Indirect Prompt Injection Risk
When exposing any language model to untrusted data, there's a risk of an
[indirect prompt injection attack](https://en.wikipedia.org/wiki/Prompt_injection).
Agentic tools like Gemini CLI, connected to MCP servers, have access to a wide
array of tools and APIs.
This MCP server grants the agent the ability to read, modify, and delete your
Google Account data, as well as other data shared with you.
- Never use this with untrusted tools
- Never include untrusted inputs into the model context. This includes asking
Gemini CLI to process mail, documents, or other resources from unverified
sources.
- Untrusted inputs may contain hidden instructions that could hijack your CLI
session. Attackers can then leverage this to modify, steal, or destroy your
data.
- Always carefully review actions taken by Gemini CLI on your behalf to ensure
they are correct and align with your intentions.
## Contributing
Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md)
file for details on how to contribute to this project.
## 📄 Legal
- **License**: [Apache License 2.0](LICENSE)
- **Terms of Service**: [Terms of Service](https://policies.google.com/terms)
- **Privacy Policy**: [Privacy Policy](https://policies.google.com/privacy)
- **Security**: [Security Policy](SECURITY.md)
================================================
FILE: SECURITY.md
================================================
# Reporting Security Issues
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
We use g.co/vulnz for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on g.co/vulnz.
================================================
FILE: cloud_function/index.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Import required packages
const functions = require('@google-cloud/functions-framework');
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const axios = require('axios');
const { URL } = require('node:url');
// --- Configuration loaded from Environment Variables ---
// These are set in the Google Cloud Function's configuration.
// They may be absent on the initial deploy (before OAuth credentials exist)
// and are set on the final deploy. Validation happens at request time.
const CLIENT_ID = process.env.CLIENT_ID;
const SECRET_NAME = process.env.SECRET_NAME;
const REDIRECT_URI = process.env.REDIRECT_URI;
// --- Configuration for local storage (used in instructions) ---
const KEYCHAIN_SERVICE_NAME = 'gemini-cli-workspace-oauth';
const KEYCHAIN_ACCOUNT_NAME = 'main-account';
// --- END CONFIGURATION ---
// Initialize the Secret Manager client
const secretClient = new SecretManagerServiceClient();
/**
* Helper function to access a secret from Secret Manager.
*/
async function getClientSecret() {
try {
const [version] = await secretClient.accessSecretVersion({
name: SECRET_NAME,
});
const payload = version.payload.data.toString('utf8');
return payload;
} catch (error) {
console.error('Failed to access secret version:', error);
throw new Error('Could not retrieve client secret.');
}
}
/**
* Handles the OAuth 2.0 callback.
* @param {Object} req Express request object.
* @param {Object} res Express response object.
*/
async function handleCallback(req, res) {
const code = req.query.code;
const state = req.query.state; // The state is the base64 encoded local redirect URI
if (!code) {
console.error('Missing authorization code in request query parameters.');
return res.status(400).send('Error: Missing authorization code.');
}
try {
const clientSecret = await getClientSecret();
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
{
client_id: CLIENT_ID,
client_secret: clientSecret,
code: code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
},
);
const { access_token, refresh_token, expires_in, scope, token_type } =
tokenResponse.data;
// Calculate expiry_date (timestamp in milliseconds)
const expiry_date = Date.now() + expires_in * 1000;
// If state is present, decode it and decide whether to redirect or show manual page.
if (state) {
try {
// SECURITY: Enforce a reasonable size limit on the state parameter to prevent DoS.
if (state.length > 4096) {
throw new Error('State parameter exceeds size limit of 4KB.');
}
const payload = JSON.parse(
Buffer.from(state, 'base64').toString('utf8'),
);
// If not in manual mode and a URI is present, perform the redirect.
if (payload && payload.manual === false && payload.uri) {
const redirectUrl = new URL(payload.uri);
// SECURITY: Validate the redirect URI to prevent open redirect attacks.
if (
redirectUrl.hostname !== 'localhost' &&
redirectUrl.hostname !== '127.0.0.1'
) {
throw new Error(
`Invalid redirect hostname: ${redirectUrl.hostname}. Must be localhost or 127.0.0.1.`,
);
}
const finalUrl = redirectUrl; // Use the validated URL object
finalUrl.searchParams.append('access_token', access_token);
if (refresh_token) {
finalUrl.searchParams.append('refresh_token', refresh_token);
}
finalUrl.searchParams.append('scope', scope);
finalUrl.searchParams.append('token_type', token_type);
finalUrl.searchParams.append('expiry_date', expiry_date.toString());
// SECURITY: Pass the CSRF token back to the client for validation.
if (payload.csrf) {
finalUrl.searchParams.append('state', payload.csrf);
}
return res.redirect(302, finalUrl.toString());
}
} catch (e) {
console.error(
'Error processing state or redirect. Falling back to manual page.',
e,
);
}
}
// --- Fallback to manual instructions ---
const credentialsJson = JSON.stringify(
{
refresh_token: refresh_token,
scope: scope,
token_type: token_type,
access_token: access_token,
expiry_date: expiry_date,
},
null,
2,
); // Pretty print JSON
// 4. Display the JSON and add a copy button + instructions
res.set('Content-Type', 'text/html');
res.status(200).send(`
<html>
<head>
<title>OAuth Token Generated</title>
<style>
body { font-family: sans-serif; display: grid; place-items: center; min-height: 90vh; background-color: #f4f7f6; padding: 1rem;}
.container { background: #fff; border: 1px solid #ccc; border-radius: 8px; padding: 2rem; box-shadow: 0 4px 12px rgba(0,0,0,0.05); max-width: 90%; width: 600px; }
h1 { color: #333; margin-top: 0;}
h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; }
textarea {
width: 100%;
min-height: 150px;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
white-space: pre;
word-break: break-all;
box-sizing: border-box; /* Include padding and border in the element's total width and height */
}
button {
display: block;
margin: 1rem auto 1rem 0; /* Align left */
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 4px;
border: none;
background-color: #4285F4;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover { background-color: #357ae8; }
button:active { background-color: #2a65d5; }
#copy-status { font-style: italic; color: green; margin-left: 10px; opacity: 0; transition: opacity 0.5s;}
.instructions { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee; font-size: 0.9em; }
code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>Success! Credentials Ready</h1>
<p>Copy the JSON block below. You'll need to store this as the password/secret in your operating system's keychain.</p>
<h3>Credentials JSON</h3>
<textarea id="credentials-json" readonly>${credentialsJson}</textarea>
<button onclick="copyCredentials()">Copy JSON</button>
<span id="copy-status">Copied!</span>
<div class="instructions">
<h4>CLI Login (Recommended):</h4>
<p>In your terminal, run:</p>
<pre style="background:#eee;padding:0.75rem;border-radius:4px;overflow-x:auto;"><code>node dist/headless-login.js</code></pre>
<p>Then paste the JSON above when prompted. The CLI will securely store your credentials.</p>
<details style="margin-top: 1.5rem;">
<summary style="cursor:pointer;color:#555;"><strong>Advanced: Manual Keychain Storage</strong></summary>
<div style="margin-top: 0.5rem;">
<ol>
<li>Open your OS Keychain/Credential Manager.</li>
<li>Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).</li>
<li>Set the <strong>Service</strong> (or equivalent field) to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>
<li>Set the <strong>Account</strong> (or username field) to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>
<li>Paste the copied JSON into the <strong>Password/Secret</strong> field.</li>
<li>Save the entry.</li>
</ol>
<p><small>(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)</small></p>
</div>
</details>
</div>
</div>
<script>
function copyCredentials() {
const textArea = document.getElementById('credentials-json');
const status = document.getElementById('copy-status');
// Use modern Clipboard API if available, with fallback to execCommand
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textArea.value).then(() => {
status.textContent = 'Copied!';
status.style.color = 'green';
}, () => {
status.textContent = 'Copy failed!';
status.style.color = 'red';
});
} else {
// Fallback for older browsers/iframes without clipboard access
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
status.textContent = 'Copied!';
status.style.color = 'green';
} else {
status.textContent = 'Copy failed!';
status.style.color = 'red';
}
} catch (err) {
status.textContent = 'Copy failed!';
status.style.color = 'red';
console.error('Fallback copy failed: ', err);
}
}
status.style.opacity = 1;
setTimeout(() => { status.style.opacity = 0; }, 2000);
// Deselect text after attempting to copy
if (window.getSelection) {window.getSelection().removeAllRanges();}
else if (document.selection) {document.selection.empty();}
}
</script>
</body>
</html>
`);
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.error('Error during token exchange:', error.response.data);
} else {
console.error(
'Error during token exchange:',
error instanceof Error ? error.message : error,
);
}
res
.status(500)
.send(
'An error occurred during the token exchange. Check function logs for details.',
);
}
}
/**
* Handles token refresh.
* Accepts a refresh_token and returns a new access_token.
* @param {Object} req Express request object.
* @param {Object} res Express response object.
*/
async function handleRefreshToken(req, res) {
// Only accept POST requests
if (req.method !== 'POST') {
console.error('Invalid method for refreshToken:', req.method);
return res.status(405).send('Method Not Allowed');
}
const { refresh_token } = req.body;
if (!refresh_token) {
console.error('Missing refresh_token in request body');
return res
.status(400)
.send('Error: Missing refresh_token in request body.');
}
try {
const clientSecret = await getClientSecret();
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
{
client_id: CLIENT_ID,
client_secret: clientSecret,
refresh_token: refresh_token,
grant_type: 'refresh_token',
},
);
const { access_token, expires_in, scope, token_type } = tokenResponse.data;
// Calculate expiry_date (timestamp in milliseconds)
const expiry_date = Date.now() + expires_in * 1000;
// Return the new credentials
// Note: Google does NOT return a new refresh_token on refresh
// The client must preserve the original refresh_token
res.status(200).json({
access_token,
expiry_date,
token_type,
scope,
});
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.error('Error during token refresh:', error.response.data);
res.status(error.response.status).json(error.response.data);
} else {
console.error(
'Error during token refresh:',
error instanceof Error ? error.message : error,
);
res.status(500).send('An error occurred during token refresh.');
}
}
}
/**
* Main entry point for the Cloud Function.
* Routes requests to either the callback handler or the refresh handler.
*/
functions.http('oauthHandler', async (req, res) => {
// Validate required environment variables at request time
if (!CLIENT_ID || !SECRET_NAME || !REDIRECT_URI) {
return res
.status(503)
.send(
'Function not yet configured. Missing required environment variables: CLIENT_ID, SECRET_NAME, REDIRECT_URI.',
);
}
// Route to refresh handler if path ends with /refresh or /refreshToken or it's a POST with refresh_token
if (
['/refresh', '/refreshToken'].includes(req.path) ||
(req.method === 'POST' && req.body?.refresh_token)
) {
return handleRefreshToken(req, res);
}
// Route to callback handler if path ends with /callback or /oauth2callback or has 'code' query param
if (['/callback', '/oauth2callback'].includes(req.path) || req.query.code) {
return handleCallback(req, res);
}
// Default/Error case
res
.status(400)
.send(
'Unknown request type. Expected OAuth callback or token refresh request.',
);
});
================================================
FILE: cloud_function/package.json
================================================
{
"name": "oauth-handler",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0",
"@google-cloud/secret-manager": "^5.0.0",
"axios": "^1.15.0"
}
}
================================================
FILE: commands/calendar/clear-schedule.toml
================================================
description = "Clear all events for a specific date or range by deleting or declining them"
prompt = """
Please help me clear my schedule for a specific date or date range. Follow these steps carefully:
1. **Identify User & Context:**
- Call `people.getMe` to get my email address and details.
- Call `time.getTimeZone` to get my local time zone.
2. **Determine Date Range:**
- Parse the date or date range from the arguments: "{{args}}".
- If no arguments are provided, use `time.getCurrentDate` to default to today.
3. **Fetch Events:**
- Call `calendar.listEvents` for the determined date range using my primary calendar.
- **Important:** Include ALL events in the list, even those often filtered out like "Commute Time", "DNS", personal blocks, or events where I am the only attendee.
4. **Review & Confirm (CRITICAL):**
- List all the events found for that period in a clear, numbered list showing the Time and Title.
- **Specifically call out any all-day events** and ask if I want to include those in the clear operation.
- **STOP** and ask me for explicit confirmation before proceeding. Example: "I found 5 events (including 1 all-day event). Do you want me to clear all of them?"
5. **Execute Clearing:**
- Once I confirm, iterate through each event in the list:
- **If I am the organizer** (check `organizer.self` is true or `organizer.email` matches mine):
- Call `calendar.deleteEvent` to remove it.
- **If I am NOT the organizer**:
- Call `calendar.respondToEvent` with `responseStatus` set to "declined".
6. **Final Report:**
- Summarize the actions taken (e.g., "Deleted 2 events and declined 3 events.").
"""
================================================
FILE: commands/calendar/get-schedule.toml
================================================
description = "Show your schedule for today, or the date specified"
prompt = """
Please show me my schedule for today. To do that use the following steps:
1) Use the people.getMe tool to get my information.
2) Use the time.getTimeZone to get my local time zone.
3) Either use the time.getCurrentDate or the user supplied date here: {{args}} to understand the current date.
4) Call calendar.list to identify the calendar associated with me from step 1.
5) Call calendar.listEvents to identify events on my calendar for the date we determined in step 3.
6) Format the list of events in my local time zone from step 2 in the following format:
> HH:MM - HH:MM: Event Title (attendance status) - Event Location
> HH:MM - HH:MM: Event 2 Title (attendance status) - Event Location
Note: If a calendar event has conflicting timezone information, prioritize the dateTime field over the timeZone field.
"""
================================================
FILE: commands/drive/search.toml
================================================
description = "Searches Google Drive for files matching a query."
prompt = """
You are tasked with searching Google Drive for files.
1) The user's search query is: {{args}}
2) Call the `drive.search` tool, passing the user's query as the `query` argument.
3) The `drive.search` tool returns a JSON object containing a list of files.
4) Format the result into a clear, readable list, showing only the `name` and `id` for each file found.
5) If no files are found, state that clearly.
Example Output Format:
> File Name: My Document (ID: 1a2b3c4d5e6f7g8h9i0j)
> File Name: Project Report (ID: k1l2m3n4o5p6q7r8s9t0)
"""
================================================
FILE: commands/gmail/search.toml
================================================
description = "Searches for emails in Gmail matching a query."
prompt = """
You are tasked with searching Gmail for emails.
1) The user's search query is: {{args}}
2) Call the `gmail.search` tool, passing the user's query as the `query` argument.
3) The `gmail.search` tool returns a JSON object containing a list of emails (id and threadId).
4) For each email found, you MUST call `gmail.get` with the `messageId` and `format='metadata'` to get the From, Subject, and Snippet.
5) Format the result into a clear, readable list.
6) If no emails are found, state that clearly.
Example Output Format:
> From: sender@example.com
Subject: Meeting Reminder
Snippet: Don't forget about the meeting tomorrow...
---
> From: newsletter@example.com
Subject: Weekly News
Snippet: Here are the top stories for this week...
"""
================================================
FILE: docs/.vitepress/config.mts
================================================
import { defineConfig } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
base: '/workspace/',
title: 'Gemini Workspace Extension',
description: 'Documentation for the Google Workspace Server Extension',
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Home', link: '/' },
{ text: 'Configuration', link: '/feature-configuration' },
{ text: 'Development', link: '/development' },
{ text: 'Release', link: '/release' },
{ text: 'Release Notes', link: '/release_notes' },
],
sidebar: [
{
text: 'Documentation',
items: [
{ text: 'Overview', link: '/' },
{ text: 'Feature Configuration', link: '/feature-configuration' },
{ text: 'Development Guide', link: '/development' },
{ text: 'GCP Setup Guide', link: '/GCP-RECREATION' },
{ text: 'Release Guide', link: '/release' },
{ text: 'Release Notes', link: '/release_notes' },
],
},
],
socialLinks: [
{
icon: 'github',
link: 'https://github.com/gemini-cli-extensions/workspace',
},
],
},
});
================================================
FILE: docs/GCP-RECREATION.md
================================================
# Recreating the GCP Project
This guide provides step-by-step instructions to recreate the Google Cloud
Platform (GCP) project and infrastructure required for the Google Workspace
Extension.
## Overview
The extension uses a "Hybrid" OAuth flow for security:
1. **Local Client**: Requests authorization from the user.
2. **Cloud Function**: Acts as a secure proxy to exchange the authorization code
for tokens. It holds the `CLIENT_SECRET` securely in Secret Manager.
3. **Secret Manager**: Stores the OAuth Client Secret.
## Prerequisites
- A Google Cloud Project with billing enabled.
- [Google Cloud CLI (gcloud)](https://cloud.google.com/sdk/docs/install)
installed and authenticated.
- Node.js and npm installed.
## Step 1: Run the Automated Setup Script
The setup script handles the full infrastructure setup in the correct order,
including guided configuration of the OAuth consent screen.
1. Set your project ID:
```bash
gcloud config set project YOUR_PROJECT_ID
```
2. Run the setup script:
```bash
./scripts/setup-gcp.sh
```
The script will:
1. Enable all required GCP APIs.
2. Guide you through configuring the **OAuth consent screen** with the required
scopes and test users (opens the Cloud Console automatically).
3. Deploy the Cloud Function and display its URL.
4. Prompt you to create an **OAuth 2.0 Client ID** in the Google Cloud Console
using the deployed function URL as the redirect URI.
5. Collect your Client ID and Client Secret.
6. Store the Client Secret in Secret Manager.
7. Update the Cloud Function with the OAuth configuration.
8. Grant the Cloud Function access to the secret.
## Step 2: Local Configuration
After running the script, set the following environment variables in your shell
(e.g., in `.zshrc` or `.bashrc`):
```bash
export WORKSPACE_CLIENT_ID="your-client-id"
export WORKSPACE_CLOUD_FUNCTION_URL="https://your-cloud-function-url"
```
The script will display the exact values to use.
Alternatively, you can modify the `DEFAULT_CONFIG` in
`workspace-server/src/utils/config.ts`.
## Why a Cloud Function?
The extension uses a Cloud Function to protect your `CLIENT_SECRET`.
- If the `CLIENT_SECRET` were included in the local extension code, anyone with
access to the extension could steal it.
- By using a Cloud Function, the secret stays in your GCP project and is only
used server-side during the token exchange.
- The local client only ever sees the resulting tokens, never the secret.
================================================
FILE: docs/development.md
================================================
# Development
This document provides instructions for developing the Google Workspace
extension.
## Development Setup and Workflow
This section guides contributors on how to build, modify, and understand the
development setup of this project.
### Setting Up the Development Environment
**Prerequisites:**
1. **Node.js**:
- **Development:** Please use Node.js `~20.19.0`. This specific version is
required due to an upstream development dependency issue. You can use a
tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions.
- **Production:** For running the CLI in a production environment, any
version of Node.js `>=20` is acceptable.
2. **Git**
### Build Process
To clone the repository:
```bash
git clone https://github.com/gemini-cli-extensions/workspace.git # Or your fork's URL
cd workspace
```
To install dependencies defined in `package.json` as well as root dependencies:
```bash
npm install
```
To build the entire project (all packages):
```bash
npm run build
```
This command typically compiles TypeScript to JavaScript, bundles assets, and
prepares the packages for execution. Refer to `scripts/build.js` and
`package.json` scripts for more details on what happens during the build.
### Running Tests
This project contains unit tests.
#### Unit Tests
To execute the unit test suite for the project:
```bash
npm run test
```
This will run tests located in the `workspace-server/src/__tests__` directory.
Ensure tests pass before submitting any changes. For a more comprehensive check,
it is recommended to run `npm run test && npm run lint`.
To test a single file, you can pass its path from the project root as an
argument. For example:
````bash
npm run test -- workspace-server/src/__tests__/GmailService.test.ts
```
### Linting and Style Checks
To ensure code quality and formatting consistency, run the linter and tests:
```bash
npm run test && npm run lint
````
This command will run ESLint, Prettier, all tests, and other checks as defined
in the project's `package.json`.
> [!TIP] After cloning create a git pre-commit hook file to ensure your commits
> are always clean.
>
> ```bash
> cat <<'EOF' > .git/hooks/pre-commit
> #!/bin/sh
> # Run tests and linting before commit
> if ! (npm run test && npm run lint); then
> echo "Pre-commit checks failed. Commit aborted."
> exit 1
> fi
> EOF
> chmod +x .git/hooks/pre-commit
> ```
#### Formatting
To separately format the code in this project by running the following command
from the root directory:
```bash
npm run format
```
This command uses Prettier to format the code according to the project's style
guidelines.
#### Linting
To separately lint the code in this project, run the following command from the
root directory:
```bash
npm run lint
```
#### Testing with Gemini CLI
To test your code changes with Gemini CLI you can run:
```bash
gemini extensions uninstall google-workspace
npm install && npm run build
gemini extensions link .
gemini extensions list
gemini --debug
# Prompt to test your feature/bug fix
```
### Coding Conventions
- Please adhere to the coding style, patterns, and conventions used throughout
the existing codebase.
- Consult
[GEMINI.md](https://github.com/gemini-cli-extensions/workspace/blob/main/GEMINI.md)
(typically found in the project root) for specific instructions related to
AI-assisted development, including conventions for comments, and Git usage.
- **Imports:** Pay special attention to import paths. The project uses ESLint to
enforce restrictions on relative imports between packages.
### Tool Naming
Tool names in source use dot notation (e.g., `docs.create`) for logical
grouping. By default, these are normalized to underscores at runtime (e.g.,
`docs_create`) for compatibility with a broader set of applications that use MCP
including Google Antigravity.
When the server is run as a Gemini CLI extension the `--use-dot-names` flag is
used to maintain dot notation and avoid breaking existing configurations.
### Project Structure
- `workspace-server/`: The main workspace for the MCP server.
- `src/`: Contains the source code for the server.
- `__tests__/`: Contains all the tests.
- `auth/`: Handles authentication.
- `cli/`: CLI tools (e.g., headless OAuth login).
- `features/`: Feature configuration registry and resolver. See the
[Feature Configuration](feature-configuration.md) docs.
- `services/`: Contains the business logic for each service.
- `utils/`: Contains utility functions.
- `config/`: Contains configuration files.
- `scripts/`: Utility scripts for building, testing, and development tasks.
## Authentication
The extension uses OAuth 2.0 to authenticate with Google Workspace APIs. The
`scripts/auth-utils.js` script provides a command-line interface to manage
authentication credentials.
### Usage
To use the script, run the following command:
```bash
npm run auth-utils -- <command>
```
### Commands
- `login`: Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell). Reads
credentials securely from `/dev/tty` so they are not visible to AI models.
- `clear`: Clear all authentication credentials.
- `expire`: Force the access token to expire (for testing refresh).
- `status`: Show current authentication status.
- `help`: Show the help message.
### Headless / Remote Environments
If you are running the server in an environment without a browser (SSH, WSL,
Cloud Shell, VMs), authentication requires manual steps:
1. Run the login tool:
```bash
npm run auth-utils -- login
```
Or, from the `workspace-server` directory:
```bash
node dist/headless-login.js
```
2. Open the printed OAuth URL in any browser (your local machine, phone, etc.).
3. Complete Google sign-in. The browser will display a credentials JSON block.
4. Copy the JSON and paste it into the CLI when prompted.
The CLI reads input from `/dev/tty` (Unix) or `CON` (Windows) rather than
process stdin, so credentials are never exposed to an AI model that may have
spawned the process.
Use `--force` to re-authenticate if credentials already exist.
### Token Storage
The extension uses a **hybrid storage strategy** for OAuth credentials. It first
attempts to use the OS-level secure storage (via the
[keytar](https://github.com/atom/node-keytar) library). If the keychain is
unavailable, it falls back to AES-256-GCM encrypted file storage.
Credentials are stored under the service name `gemini-cli-workspace-oauth` with
the account name `main-account`.
#### OS Keychain (Primary)
| Platform | Backend | How to find stored credentials |
| ----------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Keychain Access | Open **Keychain Access** → search for `gemini-cli-workspace-oauth` |
| **Windows** | Windows Credential Manager | Start Menu → search **Credential Manager** → **Windows Credentials** → **Generic Credentials** → `gemini-cli-workspace-oauth` |
| **Linux** | GNOME Keyring / KWallet (`libsecret`) | Use `secret-tool search service gemini-cli-workspace-oauth` or your desktop's keyring manager |
#### Encrypted File Fallback
When the OS keychain is not available (e.g., headless servers, containers, or CI
environments), the extension stores credentials in an encrypted file within the
extension's installation directory:
| File | Purpose |
| ---------------------------------- | ---------------------------------------------------- |
| `gemini-cli-workspace-token.json` | AES-256-GCM encrypted token data |
| `.gemini-cli-workspace-master-key` | 256-bit master key used to derive the encryption key |
Both files are created with restrictive permissions (`0o600`) and their
containing directory with `0o700`. The encryption key is derived from the master
key using `scrypt` with a machine-specific salt.
#### Forcing File Storage
To bypass the OS keychain and always use encrypted file storage, set the
environment variable:
```bash
export GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true
```
================================================
FILE: docs/feature-configuration.md
================================================
# Feature Configuration
The extension provides a feature configuration system that lets you control
which services and scopes are enabled. Each Google Workspace service is split
into **read** and **write** feature groups, giving you granular control over
what the extension can access.
## Feature Groups
| Service | Group | Scopes | Default |
| ---------- | ----- | ----------------------------------------------------------------------------- | ------- |
| `docs` | read | `documents` | ON |
| `docs` | write | `documents` | ON |
| `drive` | read | `drive.readonly` | ON |
| `drive` | write | `drive` | ON |
| `calendar` | read | `calendar.readonly` | ON |
| `calendar` | write | `calendar` | ON |
| `chat` | read | `chat.spaces.readonly`, `chat.messages.readonly`, `chat.memberships.readonly` | ON |
| `chat` | write | `chat.spaces`, `chat.messages`, `chat.memberships` | ON |
| `gmail` | read | `gmail.readonly` | ON |
| `gmail` | write | `gmail.modify` | ON |
| `people` | read | `userinfo.profile`, `directory.readonly` | ON |
| `slides` | read | `presentations.readonly` | ON |
| `slides` | write | `presentations` | **OFF** |
| `sheets` | read | `spreadsheets.readonly` | ON |
| `sheets` | write | `spreadsheets` | **OFF** |
| `time` | read | _(none)_ | ON |
| `tasks` | read | `tasks.readonly` | **OFF** |
| `tasks` | write | `tasks` | **OFF** |
**Read** groups contain tools with no side effects (search, get, list).
**Write** groups contain tools that perform mutations (create, update, delete,
send).
Services whose write scopes aren't in the published GCP project (Slides write,
Sheets write, Tasks) default to **OFF**. These can be enabled by contributors
using their own GCP projects.
## Configuration via `WORKSPACE_FEATURE_OVERRIDES`
Use the `WORKSPACE_FEATURE_OVERRIDES` environment variable to enable or disable
feature groups and individual tools.
### Syntax
```
WORKSPACE_FEATURE_OVERRIDES="key:on|off,key:on|off,..."
```
Each entry is a comma-separated `key:value` pair where:
- `key` is a feature group (e.g., `gmail.write`) or a tool name (e.g.,
`calendar.deleteEvent`)
- `value` is `on` or `off`
### Group-Level Overrides
Disable or enable entire feature groups:
```bash
# Disable Gmail write tools (send, createDraft, modify, etc.)
export WORKSPACE_FEATURE_OVERRIDES="gmail.write:off"
# Disable all of Chat
export WORKSPACE_FEATURE_OVERRIDES="chat.read:off,chat.write:off"
# Enable experimental features (Slides write, Tasks)
export WORKSPACE_FEATURE_OVERRIDES="slides.write:on,tasks.read:on,tasks.write:on"
```
### Tool-Level Overrides
Disable specific tools within an enabled group (subtractive only):
```bash
# Keep calendar.write enabled but disable delete
export WORKSPACE_FEATURE_OVERRIDES="calendar.deleteEvent:off"
# Disable destructive Gmail tools while keeping modify/label tools
export WORKSPACE_FEATURE_OVERRIDES="gmail.send:off,gmail.sendDraft:off"
# Combine group and tool overrides
export WORKSPACE_FEATURE_OVERRIDES="gmail.write:off,calendar.deleteEvent:off,slides.write:on"
```
::: warning Tool-level overrides are **subtractive only**. You cannot use
`tool:on` to enable a tool whose feature group is disabled. To enable tools,
enable their parent feature group. :::
### Precedence
The configuration follows a three-layer precedence model:
1. **Baked-in defaults** — Current services default ON; experimental services
default OFF
2. **Settings** — Future: overrides from the install-time settings UI
3. **`WORKSPACE_FEATURE_OVERRIDES`** — Highest precedence; overrides everything
### Effects
When a feature group is disabled:
- Its **tools are not registered** with the MCP server (clients won't see them)
- Its **OAuth scopes are not requested** during authentication
- If you re-enable a previously disabled feature, you may need to
re-authenticate to grant the new scopes
## Tools by Feature Group
### `docs.read`
- `docs.getSuggestions`
- `docs.getText`
### `docs.write`
- `docs.create`
- `docs.writeText`
- `docs.replaceText`
- `docs.formatText`
### `drive.read`
- `drive.getComments`
- `drive.findFolder`
- `drive.search`
- `drive.downloadFile`
### `drive.write`
- `drive.createFolder`
- `drive.moveFile`
- `drive.trashFile`
- `drive.renameFile`
### `calendar.read`
- `calendar.list`
- `calendar.listEvents`
- `calendar.getEvent`
- `calendar.findFreeTime`
### `calendar.write`
- `calendar.createEvent`
- `calendar.updateEvent`
- `calendar.respondToEvent`
- `calendar.deleteEvent`
### `chat.read`
- `chat.listSpaces`
- `chat.findSpaceByName`
- `chat.getMessages`
- `chat.findDmByEmail`
- `chat.listThreads`
### `chat.write`
- `chat.sendMessage`
- `chat.sendDm`
- `chat.setUpSpace`
### `gmail.read`
- `gmail.search`
- `gmail.get`
- `gmail.downloadAttachment`
- `gmail.listLabels`
### `gmail.write`
- `gmail.modify`
- `gmail.batchModify`
- `gmail.modifyThread`
- `gmail.send`
- `gmail.createDraft`
- `gmail.sendDraft`
- `gmail.createLabel`
### `people.read`
- `people.getUserProfile`
- `people.getMe`
- `people.getUserRelations`
### `slides.read`
- `slides.getText`
- `slides.getMetadata`
- `slides.getImages`
- `slides.getSlideThumbnail`
### `sheets.read`
- `sheets.getText`
- `sheets.getRange`
- `sheets.getMetadata`
### `time.read`
- `time.getCurrentDate`
- `time.getCurrentTime`
- `time.getTimeZone`
## For Contributors
When adding a new service or tools:
1. Define read and write feature group entries in
`workspace-server/src/features/feature-config.ts`
2. Set the default state — **ON** for scopes in the published GCP project,
**OFF** otherwise
3. Register your tools in `index.ts` as usual — the feature config wrapper
automatically skips disabled tools
This lets contributors develop and merge new features without being blocked by
the published GCP project's scope configuration. Contributors can test with
their own GCP projects by enabling the feature via
`WORKSPACE_FEATURE_OVERRIDES`.
================================================
FILE: docs/index.md
================================================
# Google Workspace Extension Documentation
This document provides an overview of the Google Workspace extension for Gemini
CLI.
## Available Tools
The extension provides the following tools:
### Authentication
- `auth.clear`: Clears the authentication credentials, forcing a re-login on the
next request.
- `auth.refreshToken`: Manually triggers the token refresh process.
### Google Docs
- `docs.create`: Creates a new Google Doc.
- `docs.getSuggestions`: Retrieves suggested edits from a Google Doc.
- `docs.getComments`: Retrieves comments from a Google Doc.
- `docs.writeText`: Writes text to a Google Doc at a specified position.
- `docs.getText`: Retrieves the text content of a Google Doc.
- `docs.replaceText`: Replaces all occurrences of a given text with new text in
a Google Doc.
- `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text
ranges in a Google Doc.
### Google Slides
- `slides.getText`: Retrieves the text content of a Google Slides presentation.
- `slides.getMetadata`: Gets metadata about a Google Slides presentation.
- `slides.getImages`: Downloads all images embedded in a Google Slides
presentation to a local directory.
- `slides.getSlideThumbnail`: Downloads a thumbnail image for a specific slide
in a Google Slides presentation to a local path.
### Google Sheets
- `sheets.getText`: Retrieves the content of a Google Sheets spreadsheet.
- `sheets.getRange`: Gets values from a specific range in a Google Sheets
spreadsheet.
- `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet.
### Google Drive
- `drive.search`: Searches for files and folders in Google Drive.
- `drive.findFolder`: Finds a folder by name in Google Drive.
- `drive.createFolder`: Creates a new folder in Google Drive.
- `drive.downloadFile`: Downloads a file from Google Drive to a local path.
- `drive.trashFile`: Moves a file or folder to the trash in Google Drive.
- `drive.renameFile`: Renames a file or folder in Google Drive.
### Google Calendar
- `calendar.list`: Lists all of the user's calendars.
- `calendar.createEvent`: Creates a new event in a calendar.
- `calendar.listEvents`: Lists events from a calendar.
- `calendar.getEvent`: Gets the details of a specific calendar event.
- `calendar.findFreeTime`: Finds a free time slot for multiple people to meet.
- `calendar.updateEvent`: Updates an existing event in a calendar.
- `calendar.respondToEvent`: Responds to a meeting invitation (accept, decline,
or tentative).
- `calendar.deleteEvent`: Deletes an event from a calendar.
### Google Chat
- `chat.listSpaces`: Lists the spaces the user is a member of.
- `chat.findSpaceByName`: Finds a Google Chat space by its display name.
- `chat.sendMessage`: Sends a message to a Google Chat space.
- `chat.getMessages`: Gets messages from a Google Chat space.
- `chat.sendDm`: Sends a direct message to a user.
- `chat.findDmByEmail`: Finds a Google Chat DM space by a user's email address.
- `chat.listThreads`: Lists threads from a Google Chat space in reverse
chronological order.
- `chat.setUpSpace`: Sets up a new Google Chat space with a display name and a
list of members.
### Gmail
- `gmail.search`: Search for emails in Gmail using query parameters.
- `gmail.get`: Get the full content of a specific email message.
- `gmail.downloadAttachment`: Downloads an attachment from a Gmail message to a
local file.
- `gmail.modify`: Modify a Gmail message.
- `gmail.batchModify`: Bulk modify up to 1,000 Gmail messages at once.
- `gmail.modifyThread`: Modify labels on all messages in a Gmail thread.
- `gmail.send`: Send an email message.
- `gmail.createDraft`: Create a draft email message.
- `gmail.sendDraft`: Send a previously created draft email.
- `gmail.listLabels`: List all Gmail labels in the user's mailbox.
- `gmail.createLabel`: Create a new Gmail label.
### Time
- `time.getCurrentDate`: Gets the current date. Returns both UTC (for API use)
and local time (for user display), along with the timezone.
- `time.getCurrentTime`: Gets the current time. Returns both UTC (for API use)
and local time (for user display), along with the timezone.
- `time.getTimeZone`: Gets the local timezone.
### People
- `people.getUserProfile`: Gets a user's profile information.
- `people.getMe`: Gets the profile information of the authenticated user.
- `people.getUserRelations`: Gets a user's relations (e.g., manager, spouse,
assistant). Defaults to the authenticated user and supports filtering by
relation type.
## Custom Commands
The extension includes several pre-configured commands for common tasks:
- `/calendar/get-schedule`: Show your schedule for today, or a specified date.
- `/calendar/clear-schedule`: Clear all events for a specific date or range by
deleting or declining them.
- `/drive/search`: Searches Google Drive for files matching a query and displays
their name and ID.
- `/gmail/search`: Searches for emails in Gmail matching a query and displays
the sender, subject, and snippet.
## Release Notes
See the [Release Notes](release_notes.md) for details on new features and
changes.
================================================
FILE: docs/release.md
================================================
# Release Process
This project uses GitHub Actions to automate the release process.
## Prerequisites
- [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated.
- Write permissions to the repository.
## Creating a Release
To streamline the release process:
1. **Update Version**: Run the `set-version` script to update the version in
`package.json` files. The `workspace-server` will now dynamically read its
version from its `package.json`.
```bash
npm run set-version <new-version> #0.0.x for example
```
2. **Commit Changes**: Commit the version bump and push the changes to `main`
(either directly or via a PR).
```bash
git commit -am "chore: bump version to <new-version>"
git push origin main
```
3. **Create Release**: Use the `gh release create` command. This will trigger
the GitHub Actions workflow to build the extension and attach the artifacts
to the release.
```bash
# Syntax: gh release create <tag> --generate-notes
gh release create v<new-version> --generate-notes
```
### What happens next?
1. **GitHub Actions Trigger**: The `release.yml` workflow is triggered by the
new tag.
2. **Build**: The workflow builds the project using `npm run build`.
3. **Package**: It creates a `workspace-server.tar.gz` file containing the
extension.
4. **Upload**: The workflow uploads the tarball to the release you just
created.
## Manual Release (Alternative)
If you prefer not to use the CLI, you can also push a tag manually:
```bash
git tag v1.0.0
git push origin v1.0.0
```
This pushes the tag to GitHub, which triggers the release workflow to create a
release and upload the artifacts. However, using `gh release create` is
recommended as it allows you to easily generate release notes.
================================================
FILE: docs/release_notes.md
================================================
# Release Notes
## 0.0.8 (2026-05-01)
### New Features
- **Google Calendar**: Added support for `eventType` (Out of Office, Focus Time,
Working Location) in Calendar Service.
- **Feature Configuration**: Introduced a new feature configuration service for
scope-based toggles, allowing more granular control over available tools.
### Improvements
- **Authentication**: Refactored OAuth scope management to use a single source
of truth and deduplicate read scopes, improving security and consistency.
- **Google Calendar**: Refactored validation logic to be independent of the
service layer.
- **Google Docs**: Improved `getText` and `getSuggestions` tools to include the
document title in their output.
### Fixes
- **Google Docs**: Resolved API errors in `docs.getText`.
- **Windows Support**: Fixed issues with `npm run clean` and handled `npm.cmd`
correctly on Windows.
- **Documentation**: Fixed various documentation links and improved clarity of
API usage.
### Chores
- **Dependencies**: Major update to TypeScript 6.0.3 and various other
dependency bumps (Vite 8, Hono 4.12, etc.).
## 0.0.7 (2026-03-11)
### Breaking Changes
- **Google Sheets**: Removed `sheets.find` tool. Use `drive.search` with MIME
type filter instead (e.g.,
`mimeType='application/vnd.google-apps.spreadsheet'`).
- **Google Slides**: Removed `slides.find` tool. Use `drive.search` with MIME
type filter instead (e.g.,
`mimeType='application/vnd.google-apps.presentation'`).
### Improvements
- **Skills**: Renamed all skill directories to `google-*` prefix (e.g.,
`calendar` → `google-calendar`) to avoid slash command conflicts.
- **Calendar Skill**: Added explicit `calendarId='primary'` mandate to prevent
agents from omitting the required parameter.
### Skills
- **Sheets Skill**: New skill with `drive.search` guidance for finding
spreadsheets.
- **Slides Skill**: New skill with `drive.search` guidance for finding
presentations.
- **Docs Skill**: Updated with `drive.search` guidance and removed stale
`docs.find`/`docs.move` references from skill and documentation.
## 0.0.6 (2026-03-08)
### New Features
- **Google Docs**: Parse rich smart chips (person, date, rich link) in document
text output.
- **Google Docs**: Added `getSuggestions` and `getComments` tools for reading
document suggestions and comments.
- **Google Docs**: Added `formatText` tool for applying rich formatting (bold,
italic, headings, etc.) to text ranges.
- **Google Calendar**: Added Google Meet link generation and Google Drive file
attachment support for `createEvent` and `updateEvent`.
- **Google Calendar**: Added `sendUpdates` parameter to `createEvent` for
controlling attendee notifications.
- **Google Drive**: Added `trashFile` tool to move files and folders to trash.
- **Google Drive**: Added `renameFile` tool to rename files and folders.
- **Gmail**: Added `batchModify` tool for bulk modifying up to 1,000 messages at
once.
- **Gmail**: Added `modifyThread` tool for modifying all messages in a Gmail
thread.
- **Gmail**: Added `threadId` support in `createDraft` for creating reply
drafts.
- **Authentication**: Added headless OAuth login for SSH, WSL, and Cloud Shell
environments.
### Skills
- **Gmail Skill**: Added rich HTML formatting guidance for email composition.
- **Chat Skill**: Added Google Chat messaging and space management guidance.
- **Docs Skill**: Added document formatting and simplified tool primitives.
- **Calendar Skill**: Added consolidated calendar scheduling guidance.
### Fixes
- **Docs**: Fixed recursion into nested child tabs in DocsService.
- **Docs**: Polished `getSuggestions` and `getComments` output formatting.
- **Drive**: Fixed shared drive file downloads.
### Documentation & Chores
- Documented token storage locations (OS keychain and encrypted file fallback).
- Updated tool reference documentation with latest features.
- **Dependencies**: Updated MCP SDK, Hono, Google APIs, rollup, ajv, qs, and
minimatch.
## 0.0.5 (2026-02-11)
### New Features
- **Gmail**: Added `createLabel` tool to manage email labels.
- **Slides**: Added `getImages` and `getSlideThumbnail` tools for better visual
integration, and included slide IDs in `getMetadata` output.
- **Drive**: Enhanced support for shared drives.
- **Calendar**: Added support for event descriptions.
- **GCP**: Added comprehensive documentation and automation for GCP project
recreation.
- **Logging**: Added authentication status updates via MCP logging for better
observability.
- **Tools**: Added annotations for read-only tools to improve agent interaction.
### Fixes
- **Security**: Resolved esbuild vulnerability via vite override.
- **Compatibility**: Normalised tool names to underscores for better
compatibility with other agents (e.g., Cursor).
- **Config**: Removed unused arguments in extension configuration.
### Documentation & Chores
- **Formatting**: Updated context documentation with Chat-specific formatting
instructions.
- **Infrastructure**: Allowed `.gemini` directory in git and added Prettier to
CI/CD pipeline.
- **Dependencies**: Updated MCP SDK, Hono, Google APIs, and other core
libraries.
## 0.0.4 (2026-01-05)
### New Features
- **Google Drive**: Added `drive.createFolder` to create new folders.
- **People**: Added `people.getUserRelations` to retrieve user relationships
(manager, reports, etc.).
- **Google Chat**: Added threading support to `chat.sendMessage` and
`chat.sendDm`, and filtering by thread in `chat.getMessages`.
- **Gmail**: Added `gmail.downloadAttachment` to download email attachments.
- **Google Drive**: Added `drive.downloadFile` to download files from Google
Drive.
- **Calendar**: Added `calendar.deleteEvent` to delete calendar events.
- **Google Docs**: Added support for Tabs in DocsService.
### Improvements
- **Dependencies**: Updated various dependencies including `@googleapis/drive`,
`google-googleapis`, and `jsdom`.
- **CI/CD**: Added a weekly preview release workflow and updated GitHub Actions
versions.
- **Testing**: Added documentation for the testing process with Gemini CLI.
### Fixes
- Fixed an issue where the `v` prefix was not stripped correctly in the release
script.
- Fixed an issue with invalid assignees in dependabot config.
- Fixed log directory creation.
## 0.0.3
- Initial release with support for Google Docs, Sheets, Slides, Drive, Calendar,
Gmail, Chat, Time, and People.
================================================
FILE: eslint.config.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const tseslint = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');
const headers = require('eslint-plugin-headers');
const importPlugin = require('eslint-plugin-import');
module.exports = [
{
ignores: [
'**/dist/',
'*.js',
'**/node_modules/',
'**/coverage/',
'!eslint.config.js',
'**/docs/.vitepress/cache/',
'**/docs/.vitepress/dist/',
],
},
{
files: ['workspace-server/src/**/*.ts'],
ignores: ['**/*.test.ts', '**/*.spec.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
ecmaVersion: 2020,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'prefer-const': 'warn',
},
},
{
files: [
'workspace-server/src/**/*.test.ts',
'workspace-server/src/**/*.spec.ts',
],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'prefer-const': 'warn',
},
},
{
files: ['./**/*.{tsx,ts,js}'],
ignores: ['workspace-server/src/index.ts'], // Has shebang which conflicts with license header
plugins: {
headers,
import: importPlugin,
},
rules: {
'headers/header-format': [
'error',
{
source: 'string',
content: [
'@license',
'Copyright (year) Google LLC',
'SPDX-License-Identifier: Apache-2.0',
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
},
},
},
],
'import/enforce-node-protocol-usage': ['error', 'always'],
},
},
{
files: ['workspace-server/src/index.ts'],
plugins: {
import: importPlugin,
},
rules: {
'import/enforce-node-protocol-usage': ['error', 'always'],
},
},
];
================================================
FILE: gemini-extension.json
================================================
{
"name": "google-workspace",
"version": "0.0.8",
"contextFileName": "workspace-server${/}WORKSPACE-Context.md",
"mcpServers": {
"google-workspace": {
"command": "node",
"args": ["scripts${/}start.js"],
"cwd": "${extensionPath}"
}
}
}
================================================
FILE: jest.config.js
================================================
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
projects: [
{
displayName: 'workspace-server',
testMatch: [
'<rootDir>/workspace-server/src/**/*.test.ts',
'<rootDir>/workspace-server/src/**/*.spec.ts',
],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: {
strict: false,
types: ['jest', 'node'],
},
},
],
},
transformIgnorePatterns: ['node_modules/'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/workspace-server/src/$1',
'\\.wasm$': '<rootDir>/workspace-server/src/__tests__/mocks/wasm.js',
},
roots: ['<rootDir>/workspace-server/src'],
setupFilesAfterEnv: ['<rootDir>/workspace-server/src/__tests__/setup.ts'],
collectCoverageFrom: [
'<rootDir>/workspace-server/src/**/*.ts',
'!<rootDir>/workspace-server/src/**/*.d.ts',
'!<rootDir>/workspace-server/src/**/*.test.ts',
'!<rootDir>/workspace-server/src/**/*.spec.ts',
'!<rootDir>/workspace-server/src/index.ts',
],
coverageDirectory: '<rootDir>/coverage',
coverageThreshold: {
global: {
branches: 45,
functions: 65,
lines: 60,
statements: 60,
},
},
},
],
coverageReporters: ['text', 'lcov', 'html'],
testTimeout: 10000,
verbose: true,
};
================================================
FILE: package.json
================================================
{
"name": "gemini-workspace-extension",
"version": "0.0.8",
"description": "Google Workspace Server Extension",
"private": true,
"bin": {
"gemini-workspace-server": "workspace-server/dist/index.js"
},
"workspaces": [
"workspace-server"
],
"scripts": {
"prepare": "npm run build",
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"test:watch": "npm run test:watch --workspaces --if-present",
"test:coverage": "npm run test:coverage --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present",
"start": "npm run start --workspaces --if-present",
"auth-utils": "npm run build:auth-utils -w workspace-server && node scripts/auth-utils.js",
"clean": "node scripts/clean.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format:check": "prettier --check .",
"format:fix": "prettier --write .",
"release": "node scripts/release.js",
"release:dev": "npm install && npm run build && node scripts/release.js",
"set-version": "node scripts/set-version.js",
"version": "node scripts/set-version.js && git add workspace-server/package.json",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@google-apps/chat": "^0.23.0",
"@google-cloud/local-auth": "^3.0.1",
"@googleapis/docs": "^9.2.1",
"@googleapis/drive": "^20.1.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"google-auth-library": "^10.6.2",
"googleapis": "^171.2.0",
"keytar": "^7.9.0"
},
"devDependencies": {
"@jest/globals": "^30.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.55.0",
"@vercel/ncc": "^0.38.3",
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"eslint-plugin-headers": "^1.3.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-license-header": "^0.9.0",
"jest": "^30.3.0",
"minimist": "^1.2.8",
"prettier": "^3.8.3",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitepress": "^1.6.4",
"vue": "^3.5.33"
},
"repository": {
"type": "git",
"url": "git+https://github.com/gemini-cli-extensions/workspace.git"
},
"keywords": [
"google-workspace",
"gmail",
"google-docs",
"google-drive",
"google-calendar",
"google-chat",
"google-people",
"google-sheets",
"google-slides",
"gemini-cli"
],
"author": "Allen Hutchison",
"license": "Apache-2.0",
"overrides": {
"vite": "$vite"
}
}
================================================
FILE: scripts/auth-utils.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const {
OAuthCredentialStorage,
} = require('../workspace-server/dist/auth-utils.js');
async function clearAuth() {
try {
await OAuthCredentialStorage.clearCredentials();
console.log('✅ Authentication credentials cleared successfully.');
} catch (error) {
console.error('❌ Failed to clear authentication credentials:', error);
process.exit(1);
}
}
async function expireToken() {
try {
const credentials = await OAuthCredentialStorage.loadCredentials();
if (!credentials) {
console.log('ℹ️ No credentials found to expire.');
return;
}
// Set expiry to 1 second ago
credentials.expiry_date = Date.now() - 1000;
await OAuthCredentialStorage.saveCredentials(credentials);
console.log('✅ Access token expired successfully.');
console.log(' Next API call will trigger proactive refresh.');
} catch (error) {
console.error('❌ Failed to expire token:', error);
process.exit(1);
}
}
async function showStatus() {
try {
const credentials = await OAuthCredentialStorage.loadCredentials();
if (!credentials) {
console.log('ℹ️ No credentials found.');
return;
}
const now = Date.now();
const expiry = credentials.expiry_date;
const hasRefreshToken = !!credentials.refresh_token;
const hasAccessToken = !!credentials.access_token;
const isExpired = expiry ? expiry < now : false;
console.log('📊 Auth Status:');
console.log(
` Access Token: ${hasAccessToken ? '✅ Present' : '❌ Missing'}`,
);
console.log(
` Refresh Token: ${hasRefreshToken ? '✅ Present' : '❌ Missing'}`,
);
if (expiry) {
console.log(` Expiry: ${new Date(expiry).toISOString()}`);
console.log(` Status: ${isExpired ? '❌ EXPIRED' : '✅ Valid'}`);
if (!isExpired) {
const minutesLeft = Math.floor((expiry - now) / 1000 / 60);
console.log(` Time left: ~${minutesLeft} minutes`);
}
} else {
console.log(` Expiry: ⚠️ Unknown`);
}
} catch (error) {
console.error('❌ Failed to get auth status:', error);
process.exit(1);
}
}
async function login() {
try {
require('../workspace-server/dist/headless-login.js');
} catch (error) {
console.error(
'❌ Failed to load headless-login module. Run "npm run build:headless-login" first.',
);
console.error(error.message);
process.exit(1);
}
}
function showHelp() {
console.log(`
Auth Management CLI
Usage: npm run auth-utils -- <command>
Commands:
login Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell)
clear Clear all authentication credentials
expire Force the access token to expire (for testing refresh)
status Show current authentication status
help Show this help message
Examples:
npm run auth-utils -- login
npm run auth-utils -- clear
npm run auth-utils -- expire
npm run auth-utils -- status
`);
}
async function main() {
const command = process.argv[2];
switch (command) {
case 'login':
await login();
break;
case 'clear':
await clearAuth();
break;
case 'expire':
await expireToken();
break;
case 'status':
await showStatus();
break;
case 'help':
case '--help':
case '-h':
showHelp();
break;
default:
if (!command) {
console.error('❌ No command specified.');
} else {
console.error(`❌ Unknown command: ${command}`);
}
showHelp();
process.exit(1);
}
}
main();
================================================
FILE: scripts/clean.js
================================================
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const { rmSync, readFileSync } = require('node:fs');
const { join } = require('node:path');
const root = join(__dirname, '..');
const RMRF = { recursive: true, force: true };
function rmrfSyncVerbose(path) {
console.log(`Removing ${path}`);
rmSync(path, RMRF);
}
// Clean up all workspaces.
const { workspaces } = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf-8'),
);
for (const workspace of workspaces) {
rmrfSyncVerbose(join(root, workspace, 'dist'));
}
// Root artifacts.
rmrfSyncVerbose(join(root, 'node_modules'));
rmrfSyncVerbose(join(root, 'release'));
rmrfSyncVerbose(join(root, 'logs'));
rmrfSyncVerbose(join(root, 'docs', '.vitepress', 'cache'));
rmrfSyncVerbose(join(root, 'docs', '.vitepress', 'dist'));
================================================
FILE: scripts/list-deps.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const path = require('node:path');
const { getTransitiveDependencies } = require('./utils/dependencies');
const root = path.join(__dirname, '..');
const targetPackages = process.argv.slice(2);
if (targetPackages.length === 0) {
console.log('Usage: node scripts/list-deps.js <package1> [package2...]');
process.exit(1);
}
console.log(`Analyzing dependencies for: ${targetPackages.join(', ')}`);
const allDeps = getTransitiveDependencies(root, targetPackages);
console.log('\nTransitive Dependencies:');
Array.from(allDeps)
.sort()
.forEach((dep) => {
console.log(`- ${dep}`);
});
================================================
FILE: scripts/print-scopes.ts
================================================
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Prints the OAuth scopes that should be registered on the GCP consent
* screen, one per line. Sourced from FEATURE_GROUPS so the registration
* list and the runtime request list cannot drift.
*
* Used by scripts/setup-gcp.sh.
*/
import { getAllPossibleScopes } from '../workspace-server/src/features/feature-config';
for (const scope of getAllPossibleScopes()) {
console.log(scope);
}
================================================
FILE: scripts/release.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
const path = require('node:path');
const archiver = require('archiver');
const argv = require('minimist')(process.argv.slice(2));
const deleteFilesByExtension = (dir, ext) => {
if (!fs.existsSync(dir)) {
return;
}
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.lstatSync(filePath);
if (stat.isDirectory()) {
deleteFilesByExtension(filePath, ext);
} else if (filePath.endsWith(ext)) {
fs.unlinkSync(filePath);
}
}
};
const main = async () => {
const platform = argv.platform;
if (platform && typeof platform !== 'string') {
console.error(
'Error: The --platform argument must be a string (e.g., --platform=linux).',
);
process.exit(1);
}
const baseName = 'google-workspace-extension';
const name = platform ? `${platform}.${baseName}` : baseName;
const extension = 'tar.gz';
const rootDir = path.join(__dirname, '..');
const releaseDir = path.join(rootDir, 'release');
fs.rmSync(releaseDir, { recursive: true, force: true });
const archiveName = `${name}.${extension}`;
const archiveDir = path.join(releaseDir, name);
const workspaceMcpServerDir = path.join(rootDir, 'workspace-server');
// Create the release directory
fs.mkdirSync(releaseDir, { recursive: true });
// Create the platform-specific directory
fs.mkdirSync(archiveDir, { recursive: true });
// Copy the dist directory
fs.cpSync(
path.join(workspaceMcpServerDir, 'dist'),
path.join(archiveDir, 'dist'),
{ recursive: true },
);
// Clean up the dist directory
const distDir = path.join(archiveDir, 'dist');
deleteFilesByExtension(distDir, '.d.ts');
deleteFilesByExtension(distDir, '.map');
fs.rmSync(path.join(distDir, '__tests__'), { recursive: true, force: true });
fs.rmSync(path.join(distDir, 'auth'), { recursive: true, force: true });
fs.rmSync(path.join(distDir, 'services'), { recursive: true, force: true });
fs.rmSync(path.join(distDir, 'utils'), { recursive: true, force: true });
// Copy native modules and dependencies (keytar, jsdom)
const nodeModulesDir = path.join(archiveDir, 'node_modules');
fs.mkdirSync(nodeModulesDir, { recursive: true });
const { getTransitiveDependencies } = require('./utils/dependencies');
const visited = getTransitiveDependencies(rootDir, ['keytar', 'jsdom']);
visited.forEach((pkg) => {
const source = path.join(rootDir, 'node_modules', pkg);
const dest = path.join(nodeModulesDir, pkg);
if (fs.existsSync(source)) {
fs.cpSync(source, dest, { recursive: true });
}
});
const packageJson = require('../package.json');
const version = (process.env.GITHUB_REF_NAME || packageJson.version).replace(
/^v/,
'',
);
// Generate the gemini-extension.json file
const geminiExtensionJson = {
name: 'google-workspace',
version,
contextFileName: 'WORKSPACE-Context.md',
mcpServers: {
'google-workspace': {
command: 'node',
args: ['dist/index.js', '--use-dot-names'],
cwd: '${extensionPath}',
},
},
};
fs.writeFileSync(
path.join(archiveDir, 'gemini-extension.json'),
JSON.stringify(geminiExtensionJson, null, 2),
);
// Copy the WORKSPACE-Context.md file
fs.copyFileSync(
path.join(workspaceMcpServerDir, 'WORKSPACE-Context.md'),
path.join(archiveDir, 'WORKSPACE-Context.md'),
);
// Copy the commands directory
const commandsDir = path.join(rootDir, 'commands');
if (fs.existsSync(commandsDir)) {
fs.cpSync(commandsDir, path.join(archiveDir, 'commands'), {
recursive: true,
});
}
// Copy the skills directory
const skillsDir = path.join(rootDir, 'skills');
if (fs.existsSync(skillsDir)) {
fs.cpSync(skillsDir, path.join(archiveDir, 'skills'), {
recursive: true,
});
}
// Create the archive
const output = fs.createWriteStream(path.join(releaseDir, archiveName));
const archive = archiver('tar', {
gzip: true,
});
const archivePromise = new Promise((resolve, reject) => {
output.on('close', function () {
console.log(archive.pointer() + ' total bytes');
console.log(
'archiver has been finalized and the output file descriptor has closed.',
);
resolve();
});
archive.on('error', function (err) {
reject(err);
});
});
archive.pipe(output);
archive.directory(archiveDir, false);
archive.finalize();
await archivePromise;
};
main().catch((err) => {
console.error(err);
process.exit(1);
});
================================================
FILE: scripts/set-version.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
const path = require('node:path');
const rootDir = path.join(__dirname, '..');
const packageJsonPath = path.join(rootDir, 'package.json');
const workspaceServerPackageJsonPath = path.join(
rootDir,
'workspace-server',
'package.json',
);
const geminiExtensionJsonPath = path.join(rootDir, 'gemini-extension.json');
const workspaceServerIndexPath = path.join(
rootDir,
'workspace-server',
'src',
'index.ts',
);
const updateJsonFile = (filePath, version) => {
try {
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
content.version = version;
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
console.log(
`Updated ${path.relative(rootDir, filePath)} to version ${version}`,
);
} catch (error) {
console.error(
`Failed to update JSON file at ${path.relative(rootDir, filePath)}:`,
error,
);
process.exit(1);
}
};
const main = () => {
let version = process.argv[2];
if (version) {
// If version is provided as arg, update root package.json first
updateJsonFile(packageJsonPath, version);
} else {
// Otherwise read from root package.json
const packageJson = require(packageJsonPath);
version = packageJson.version;
console.log(`Using version from package.json: ${version}`);
}
if (!version) {
console.error('No version specified and no version found in package.json');
process.exit(1);
}
updateJsonFile(workspaceServerPackageJsonPath, version);
updateJsonFile(geminiExtensionJsonPath, version);
};
main();
================================================
FILE: scripts/setup-gcp.sh
================================================
#!/bin/bash
# GCP Setup Script for Google Workspace Extension
# This script is idempotent — it can be safely re-run without breaking existing config.
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper to open a URL in the default browser
open_url() {
if command -v open &> /dev/null; then
open "$1"
elif command -v xdg-open &> /dev/null; then
xdg-open "$1"
else
echo -e "Could not open browser automatically."
fi
}
echo -e "${YELLOW}Starting Google Cloud Platform setup...${NC}"
# Check if gcloud is installed
if ! command -v gcloud &> /dev/null; then
echo -e "${RED}Error: gcloud CLI is not installed. Please install it first.${NC}"
exit 1
fi
# Get current project ID
PROJECT_ID=$(gcloud config get-value project 2>/dev/null)
if [ -z "$PROJECT_ID" ]; then
echo -e "${RED}Error: No Google Cloud project is currently set.${NC}"
echo "Please run: gcloud config set project [PROJECT_ID]"
exit 1
fi
echo -e "Using project: ${GREEN}$PROJECT_ID${NC}"
SECRET_ID="workspace-oauth-client-secret"
FUNCTION_NAME="workspace-oauth-handler"
# 1. Enable Required APIs
echo -e "\n${YELLOW}Step 1: Enabling Required APIs...${NC}"
APIS=(
"drive.googleapis.com"
"docs.googleapis.com"
"calendar-json.googleapis.com"
"chat.googleapis.com"
"gmail.googleapis.com"
"people.googleapis.com"
"slides.googleapis.com"
"sheets.googleapis.com"
"admin.googleapis.com"
"secretmanager.googleapis.com"
"cloudfunctions.googleapis.com"
"cloudbuild.googleapis.com"
"run.googleapis.com"
"artifactregistry.googleapis.com"
)
for api in "${APIS[@]}"; do
echo "Enabling $api..."
gcloud services enable "$api"
done
echo -e "${GREEN}APIs enabled successfully.${NC}"
# 2. Configure OAuth Consent Screen
echo -e "\n${YELLOW}Step 2: Configure OAuth Consent Screen${NC}"
echo -e "The OAuth consent screen must be configured before creating credentials."
echo ""
CONSENT_URL="https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID"
echo -e "Opening the OAuth consent screen configuration page..."
open_url "$CONSENT_URL"
echo ""
echo -e "If the page did not open, go to:"
echo -e " ${GREEN}$CONSENT_URL${NC}"
echo ""
echo -e "Configure the consent screen with these settings:"
echo -e " 1. Select ${GREEN}Internal${NC} (Google Workspace) or ${GREEN}External${NC}"
echo -e " 2. Fill in the App name and Support email"
echo -e " 3. Add the following ${GREEN}scopes${NC} (listed below)"
echo -e " 4. Under ${GREEN}Test users${NC}, add the email addresses of anyone"
echo -e " who will use this extension (required while in Testing mode)"
echo ""
# Single source of truth: scopes are computed from FEATURE_GROUPS in
# workspace-server/src/features/feature-config.ts. See issue #323.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCOPES_OUTPUT=$(cd "$REPO_ROOT" && npx --no-install ts-node --transpile-only --project scripts/tsconfig.json scripts/print-scopes.ts 2>&1)
if [ $? -ne 0 ]; then
echo -e "${RED}Error: Failed to compute OAuth scopes from feature-config.ts.${NC}"
echo -e "${RED}Did you run 'npm install' at the repo root?${NC}"
echo "$SCOPES_OUTPUT"
exit 1
fi
SCOPES=()
# Filter to https:// lines so any Node/ts-node warnings written to stderr
# (captured via 2>&1 above so we can surface them on failure) don't end up
# in the user-visible scope list.
while IFS= read -r line; do
[[ "$line" == https://* ]] && SCOPES+=("$line")
done <<< "$SCOPES_OUTPUT"
if [ ${#SCOPES[@]} -eq 0 ]; then
echo -e "${RED}Error: print-scopes.ts produced no scope output.${NC}"
echo "$SCOPES_OUTPUT"
exit 1
fi
for scope in "${SCOPES[@]}"; do
echo -e " ${GREEN}$scope${NC}"
done
echo ""
echo -e "${YELLOW}Have you finished configuring the OAuth consent screen? (y/n)${NC}"
read CONSENT_DONE
if [ "$CONSENT_DONE" != "y" ] && [ "$CONSENT_DONE" != "Y" ]; then
echo -e "${RED}Please configure the OAuth consent screen before continuing.${NC}"
echo -e "Re-run this script when ready."
exit 1
fi
# 3. Deploy Cloud Function
echo -e "\n${YELLOW}Step 3: Deploying Cloud Function...${NC}"
echo -e "${YELLOW}Please enter the GCP region for your Cloud Function (e.g., us-central1):${NC}"
read REGION
if [ -z "$REGION" ]; then
REGION="us-central1"
echo -e "${YELLOW}No region entered, defaulting to $REGION.${NC}"
fi
# Check if the function already exists and get its URL
FUNCTION_URL=""
if gcloud functions describe "$FUNCTION_NAME" --region="$REGION" &> /dev/null; then
FUNCTION_URL=$(gcloud functions describe "$FUNCTION_NAME" --region="$REGION" --format='value(serviceConfig.uri)')
echo -e "${GREEN}Cloud Function already exists at: $FUNCTION_URL${NC}"
echo -e "It will be updated with the final configuration in a later step."
else
echo "Deploying Cloud Function (initial)..."
gcloud functions deploy "$FUNCTION_NAME" \
--gen2 \
--runtime=nodejs22 \
--region="$REGION" \
--source="./cloud_function" \
--entry-point=oauthHandler \
--trigger-http \
--allow-unauthenticated
FUNCTION_URL=$(gcloud functions describe "$FUNCTION_NAME" --region="$REGION" --format='value(serviceConfig.uri)')
fi
if [ -z "$FUNCTION_URL" ]; then
echo -e "${RED}Error: Could not retrieve Cloud Function URL. Please check the deployment logs.${NC}"
exit 1
fi
echo -e "${GREEN}Cloud Function URL: $FUNCTION_URL${NC}"
# 4. Collect OAuth credentials
echo -e "\n${YELLOW}Step 4: Configuring OAuth credentials...${NC}"
echo -e "Create an OAuth 2.0 Client ID in the Google Cloud Console"
echo -e "(or locate your existing one):"
echo -e " 1. Go to APIs & Services > Credentials > Create Credentials > OAuth client ID"
echo -e " 2. Select ${GREEN}Web application${NC}"
echo -e " 3. Add the following as an Authorized redirect URI:"
echo -e " ${GREEN}$FUNCTION_URL${NC}"
echo -e " 4. Copy the Client ID and Client Secret"
echo ""
CREDENTIALS_URL="https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
echo -e "Opening the Credentials page..."
open_url "$CREDENTIALS_URL"
echo ""
echo -e "If the page did not open, go to:"
echo -e " ${GREEN}$CREDENTIALS_URL${NC}"
echo ""
echo -e "${YELLOW}Please enter the OAuth 2.0 Client ID:${NC}"
read CLIENT_ID
if [ -z "$CLIENT_ID" ]; then
echo -e "${RED}Error: Client ID cannot be empty.${NC}"
exit 1
fi
echo -e "${YELLOW}Please enter the OAuth 2.0 Client Secret:${NC}"
read -s CLIENT_SECRET
echo
if [ -z "$CLIENT_SECRET" ]; then
echo -e "${RED}Error: Client Secret cannot be empty.${NC}"
exit 1
fi
# 5. Setup Secret Manager
echo -e "\n${YELLOW}Step 5: Storing Client Secret in Secret Manager...${NC}"
if gcloud secrets describe "$SECRET_ID" &> /dev/null; then
echo "Secret $SECRET_ID already exists. Adding new version..."
else
echo "Creating secret $SECRET_ID..."
gcloud secrets create "$SECRET_ID" --replication-policy=automatic
fi
echo -n "$CLIENT_SECRET" | gcloud secrets versions add "$SECRET_ID" --data-file=-
echo -e "${GREEN}Secret stored successfully.${NC}"
# 6. Update Cloud Function with OAuth configuration
echo -e "\n${YELLOW}Step 6: Updating Cloud Function with OAuth configuration...${NC}"
gcloud functions deploy "$FUNCTION_NAME" \
--gen2 \
--runtime=nodejs22 \
--region="$REGION" \
--source="./cloud_function" \
--entry-point=oauthHandler \
--trigger-http \
--allow-unauthenticated \
--set-env-vars "CLIENT_ID=$CLIENT_ID,SECRET_NAME=projects/$PROJECT_ID/secrets/$SECRET_ID/versions/latest,REDIRECT_URI=$FUNCTION_URL"
echo -e "${GREEN}Cloud Function updated with OAuth configuration.${NC}"
# 7. Grant Permissions
echo -e "\n${YELLOW}Step 7: Granting Secret Manager Access to Cloud Function...${NC}"
SERVICE_ACCOUNT=$(gcloud functions describe "$FUNCTION_NAME" --region="$REGION" --format='value(serviceConfig.serviceAccountEmail)')
gcloud secrets add-iam-policy-binding "$SECRET_ID" \
--member="serviceAccount:$SERVICE_ACCOUNT" \
--role="roles/secretmanager.secretAccessor"
echo -e "${GREEN}Permissions granted successfully.${NC}"
echo -e "\n${GREEN}GCP Setup Complete!${NC}"
echo -e "---------------------------------------------------"
echo -e "${YELLOW}Next Steps:${NC}"
echo "Set the following environment variables in your local environment:"
echo -e " ${GREEN}export WORKSPACE_CLIENT_ID=\"$CLIENT_ID\"${NC}"
echo -e " ${GREEN}export WORKSPACE_CLOUD_FUNCTION_URL=\"$FUNCTION_URL\"${NC}"
echo -e "---------------------------------------------------"
================================================
FILE: scripts/start.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const { spawn } = require('node:child_process');
const path = require('node:path');
function runCommand(command, args, options) {
return new Promise((resolve, reject) => {
// On Windows, npm is a batch file and needs a shell
if (process.platform === 'win32' && command === 'npm') {
command = `${command}.cmd`;
options = { ...options, shell: true };
}
const child = spawn(command, args, options);
// Pipe stderr to the parent process's stderr if it's available.
// This is more efficient than listening for 'data' events.
if (child.stderr) {
child.stderr.pipe(process.stderr);
}
child.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`Command failed with code ${code}: ${command} ${args.join(' ')}`,
),
);
} else {
resolve();
}
});
child.on('error', (err) => {
reject(err);
});
});
}
async function main() {
try {
await runCommand('npm', ['install'], {
stdio: ['ignore', 'ignore', 'pipe'],
});
const SERVER_PATH = path.join(
__dirname,
'..',
'workspace-server',
'dist',
'index.js',
);
await runCommand('node', [SERVER_PATH, '--debug', '--use-dot-names'], {
stdio: 'inherit',
});
} catch (error) {
console.error(error);
process.exit(1);
}
}
main();
================================================
FILE: scripts/tsconfig.json
================================================
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": ".."
},
"include": ["**/*.ts", "../workspace-server/src/**/*"]
}
================================================
FILE: scripts/utils/dependencies.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
const path = require('node:path');
/**
* Gets the direct dependencies of a package from its package.json.
* @param {string} rootDir - The root directory containing node_modules.
* @param {string} pkgName - The name of the package.
* @returns {string[]} - A list of dependency names.
*/
function getDependencies(rootDir, pkgName) {
const pkgPath = path.join(rootDir, 'node_modules', pkgName, 'package.json');
if (!fs.existsSync(pkgPath)) {
return [];
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return Object.keys(pkg.dependencies || {});
}
/**
* Recursively finds all transitive dependencies for a list of packages.
* @param {string} rootDir - The root directory containing node_modules.
* @param {string[]} startPkgs - The list of initial packages to resolve.
* @returns {Set<string>} - A set of all transitive dependencies (including startPkgs).
*/
function getTransitiveDependencies(rootDir, startPkgs) {
const visited = new Set();
const toVisit = [...startPkgs];
while (toVisit.length > 0) {
const pkg = toVisit.pop();
if (visited.has(pkg)) continue;
visited.add(pkg);
const deps = getDependencies(rootDir, pkg);
deps.forEach((dep) => {
if (!visited.has(dep)) {
toVisit.push(dep);
}
});
}
return visited;
}
module.exports = {
getDependencies,
getTransitiveDependencies,
};
================================================
FILE: skills/gmail/SKILL.md
================================================
---
name: gmail
description: >
CRITICAL: You MUST activate this skill BEFORE composing, sending, drafting, or
searching emails. Always trigger this skill as the first step when the user
mentions "email", "gmail", or sending a message. Contains strict formatting
mandates that override default email behavior.
---
# Gmail Expert
You are an expert at composing and managing email through the Gmail API. Follow
these guidelines when helping users with email tasks.
## Rich Text Email Formatting
When composing emails (via `gmail.send` or `gmail.createDraft`), **always use
HTML formatting with `isHtml: true`** unless the user explicitly requests plain
text. Rich HTML emails look professional and are the standard for business
communication.
### Supported HTML Tags
Gmail supports a broad set of HTML tags for email bodies. Use these freely:
| Category | Tags |
| :------- | :---------------------------------------------------------------- |
| Text | `<p>`, `<br>`, `<span>`, `<div>`, `<blockquote>`, `<pre>`, `<hr>` |
| Headings | `<h1>` through `<h6>` |
| Emphasis | `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<s>`, `<strike>` |
| Code | `<code>`, `<pre>` |
| Lists | `<ul>`, `<ol>`, `<li>` |
| Tables | `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` |
| Links | `<a href="...">` |
| Images | `<img src="..." alt="...">` |
### Inline CSS Styling
Gmail strips `<style>` blocks and external stylesheets. **Always use inline
CSS** via the `style` attribute:
```html
<!-- ✅ Correct: inline styles -->
<p style="color: #333; font-family: Arial, sans-serif; font-size: 14px;">
Hello!
</p>
<!-- ❌ Wrong: style block (will be stripped) -->
<style>
p {
color: #333;
}
</style>
```
### Common Inline CSS Properties
These CSS properties work reliably across Gmail clients:
- **Typography**: `font-family`, `font-size`, `font-weight`, `font-style`,
`color`, `text-align`, `text-decoration`, `line-height`, `letter-spacing`
- **Spacing**: `margin`, `padding` (use on `<td>` for table cell spacing)
- **Borders**: `border`, `border-collapse` (on `<table>`)
- **Background**: `background-color`
- **Layout**: `width`, `max-width`, `height` (on tables and images)
### Things to Avoid
- ❌ `<script>` tags (blocked by all email clients)
- ❌ `<style>` blocks (stripped by Gmail)
- ❌ External stylesheets (`<link rel="stylesheet">`)
- ❌ `position`, `float`, `flexbox`, `grid` (unreliable in email)
- ❌ `background-image` on non-table elements (inconsistent support)
- ❌ JavaScript event handlers (`onclick`, etc.)
- ❌ Form elements (`<input>`, `<select>`, `<textarea>`)
### Email Template Examples
#### Professional Message
```html
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;">
<p>Hi Team,</p>
<p>Please find the <b>Q4 results</b> summarized below:</p>
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
<thead>
<tr style="background-color: #f2f2f2;">
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">
Metric
</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">
Result
</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">Revenue</td>
<td style="border: 1px solid #ddd; padding: 8px;">$1.2M</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">Growth</td>
<td style="border: 1px solid #ddd; padding: 8px;">+15%</td>
</tr>
</tbody>
</table>
<p>Key takeaways:</p>
<ul>
<li>Revenue exceeded targets by <b>12%</b></li>
<li>Customer retention improved to <b>94%</b></li>
</ul>
<p>
Best regards,<br />
<span style="color: #666;">— The Analytics Team</span>
</p>
</div>
```
#### Styled Action Email
```html
<div
style="font-family: 'Helvetica Neue', Arial, sans-serif; max-width: 600px;"
>
<h2 style="color: #1a73e8; margin-bottom: 8px;">Action Required</h2>
<p style="font-size: 14px; color: #555;">
Your review is needed on the following document:
</p>
<p>
<a
href="https://docs.google.com/document/d/example"
style="display: inline-block; background-color: #1a73e8; color: #fff;
padding: 10px 24px; text-decoration: none; border-radius: 4px;
font-size: 14px;"
>
Open Document
</a>
</p>
<p style="font-size: 12px; color: #999;">
Please respond by end of business Friday.
</p>
</div>
```
## Gmail Search Syntax
Use Gmail search operators with `gmail.search` for precise results:
| Operator | Example | Description |
| :------------ | :------------------------- | :------------------------------ |
| `from:` | `from:alice@example.com` | Sender |
| `to:` | `to:bob@example.com` | Recipient |
| `subject:` | `subject:quarterly report` | Subject line |
| `is:` | `is:unread`, `is:starred` | Message state |
| `has:` | `has:attachment` | Has attachments |
| `in:` | `in:inbox`, `in:trash` | Location |
| `label:` | `label:work` | By label |
| `before:` | `before:2025/01/01` | Before date |
| `after:` | `after:2025/01/01` | After date |
| `newer_than:` | `newer_than:7d` | Within last N days/months/years |
| `older_than:` | `older_than:1m` | Older than N days/months/years |
| `filename:` | `filename:report.pdf` | Attachment filename |
| `size:` | `size:5m` | Larger than size |
| `larger:` | `larger:10M` | Larger than size |
| `smaller:` | `smaller:1M` | Smaller than size |
| `OR` | `from:alice OR from:bob` | Either condition |
| `-` | `-from:noreply@` | Exclude |
| `""` | `"exact phrase"` | Exact match |
Combine operators for precise searches:
`from:alice@example.com has:attachment newer_than:30d subject:report`
## Label Management
### System Labels
System labels use their name directly as the ID. Use `gmail.modify` to apply
these common operations:
| Label | Add Effect | Remove Effect |
| :---------- | :--------------- | :-------------------------- |
| `INBOX` | Move to inbox | Archive (remove from inbox) |
| `UNREAD` | Mark as unread | Mark as read |
| `STARRED` | Star the message | Unstar |
| `IMPORTANT` | Mark important | Mark not important |
| `SPAM` | Mark as spam | Remove spam classification |
| `TRASH` | Move to trash | Remove from trash |
### Custom Labels
For user-created labels, you must resolve the label ID first:
1. Call `gmail.listLabels()` to get all labels with their IDs
2. Match the desired label by name
3. Use the label ID (e.g., `Label_42`) in `gmail.modify`
## Downloading Attachments
1. Use `gmail.get` with `format: 'full'` to get attachment metadata (IDs and
filenames)
2. Use `gmail.downloadAttachment` with the `messageId` and `attachmentId`
3. **Always use absolute paths** for `localPath` (e.g.,
`/Users/username/Downloads/file.pdf`). Relative paths will be rejected.
## Threading and Replies
- Use `threadId` with `gmail.createDraft` to create a reply draft linked to an
existing conversation
- The service automatically fetches reply headers (`In-Reply-To`, `References`)
from the thread to maintain proper threading
- Always reference previous messages when replying for context continuity
================================================
FILE: skills/google-calendar/SKILL.md
================================================
---
name: google-calendar
description: >
CRITICAL: You MUST activate this skill BEFORE creating, querying, or managing
calendar events. Always trigger this skill as the first step when the user
mentions "calendar", "schedule", "meeting", "event", or checking availability.
Contains strict behavioral mandates that override default calendar behavior.
---
# Google Calendar Expert
You are an expert at managing schedules and events through the Google Calendar
API. Follow these guidelines when helping users with calendar tasks.
## Timezone-First Workflow
**Always establish the user's timezone before any calendar operation:**
1. Call `time.getTimeZone()` (or `time.getCurrentTime()`) to get the user's
local timezone
2. Use this timezone for all time displays and event creation
3. Always include the timezone abbreviation (EST, PST, etc.) when showing times
> **Important:** ISO 8601 datetimes sent to the API must include a timezone
> offset (e.g., `2025-01-15T10:30:00-05:00`) or use UTC (`Z`). Never send "bare"
> datetimes without an offset.
## Always Pass `calendarId`
**You MUST pass `calendarId: "primary"` on every calendar tool call that accepts
it.** Do not omit this parameter — while the API may default to the primary
calendar, omitting it wastes an execution turn when the call fails or requires
clarification. Always include it explicitly:
- `calendar.listEvents({ calendarId: "primary", ... })`
- `calendar.createEvent({ calendarId: "primary", ... })`
- `calendar.getEvent({ eventId: "...", calendarId: "primary" })`
- `calendar.updateEvent({ eventId: "...", calendarId: "primary", ... })`
- `calendar.deleteEvent({ eventId: "...", calendarId: "primary" })`
- `calendar.respondToEvent({ eventId: "...", calendarId: "primary", ... })`
Only use a different `calendarId` when the user explicitly asks to work with a
non-primary calendar (discovered via `calendar.list`).
## Understanding "Next Meeting"
When asked about "next meeting", "today's schedule", or similar queries:
1. **Fetch the full day's context** — Use `calendar.listEvents` with
`calendarId: "primary"`, start of day (`00:00:00`) to end of day (`23:59:59`)
in the user's timezone
2. **Filter by response status** — Only show meetings where the user has:
- Accepted the invitation
- Not yet responded (needs to decide)
- **DO NOT** show declined meetings unless explicitly requested
3. **Compare with current time** — Identify meetings relative to now
4. **Handle edge cases**:
- If a meeting is in progress, mention it first
- "Next" means the first meeting after the current time
- Keep the full day context for follow-up questions
## Meeting Response Filtering
Use the `attendeeResponseStatus` parameter on `calendar.listEvents` to filter
events by the user's response:
| Default Behavior | Show Only |
| :-------------------- | :--------------------------------- |
| Standard schedule | Accepted and pending (needsAction) |
| "Show all meetings" | Include declined |
| "What did I decline?" | Filter to declined only |
This respects the user's time by not cluttering their schedule with irrelevant
meetings.
## Creating Events
Use `calendar.createEvent` to add new events. **Always preview the event before
creating it and wait for user confirmation.**
### Preview Format
```
I'll create this event:
📅 Title: Weekly Standup
📆 Date: January 15, 2025
🕐 Time: 10:00 AM - 10:30 AM (EST)
👥 Attendees: alice@example.com, bob@example.com
📝 Description: Weekly team sync
🎥 Google Meet: Will be generated
📎 Attachments: Q1 Agenda (Google Doc)
Should I create this event?
```
### Key Parameters
- **`calendarId`** — **Always pass `"primary"`**. Use `calendar.list` to
discover other calendars when needed.
- **`start` / `end`** — Two formats:
- **Timed events**: `{ dateTime: "2025-01-15T10:00:00-05:00" }` — ISO 8601
with timezone offset
- **All-day events**: `{ date: "2025-01-15" }` — YYYY-MM-DD format. The end
date is exclusive (use the next day).
- **`attendees`** — Array of email addresses
- **`addGoogleMeet`** — Set to `true` to automatically generate a Google Meet
link (available in response's `hangoutLink` field)
- **`attachments`** — Array of Google Drive file attachments (fileUrl, title,
optional mimeType). Providing attachments fully replaces any existing
attachments.
- **`sendUpdates`** — Controls email notifications:
- `"all"` — Notify all attendees (default when attendees are provided)
- `"externalOnly"` — Only notify non-organization attendees
- `"none"` — No notifications
- **`eventType`** — The type of event (see
[Calendar Status Events](#calendar-status-events) below):
- `"default"` — Regular event (default if omitted)
- `"focusTime"` — Focus time block
- `"outOfOffice"` — Out-of-office event
- `"workingLocation"` — Working location indicator
### Example — Regular Timed Event
```
calendar.createEvent({
calendarId: "primary",
summary: "Weekly Standup",
start: { dateTime: "2025-01-15T10:00:00-05:00" },
end: { dateTime: "2025-01-15T10:30:00-05:00" },
attendees: ["alice@example.com", "bob@example.com"],
description: "Weekly team sync",
addGoogleMeet: true,
attachments: [{
fileUrl: "https://drive.google.com/file/d/abc123/edit",
title: "Q1 Agenda",
mimeType: "application/vnd.google-apps.document"
}],
sendUpdates: "all"
})
```
### Example — All-Day Event
```
calendar.createEvent({
calendarId: "primary",
summary: "Team Offsite",
start: { date: "2025-01-15" },
end: { date: "2025-01-17" },
description: "Two-day team offsite"
})
```
## Calendar Status Events
`calendar.createEvent` supports creating focus time, out-of-office, and working
location events via the `eventType` parameter. These are all created through the
same tool — there are no separate tools for each type.
### Focus Time
Blocks concentrated work periods. Can auto-decline conflicting meetings.
> **Constraint:** Focus time events **cannot be all-day events** — they must use
> `dateTime`, not `date`.
```
calendar.createEvent({
calendarId: "primary",
eventType: "focusTime",
start: { dateTime: "2025-01-15T09:00:00-05:00" },
end: { dateTime: "2025-01-15T12:00:00-05:00" },
focusTimeProperties: {
chatStatus: "doNotDisturb",
autoDeclineMode: "declineOnlyNewConflictingInvitations",
declineMessage: "In focus mode, will respond later"
}
})
```
- **`summary`** defaults to `"Focus Time"` if omitted
- **`focusTimeProperties.chatStatus`** — `"doNotDisturb"` (default) or
`"available"`
- **`focusTimeProperties.autoDeclineMode`** —
`"declineOnlyNewConflictingInvitations"` (default),
`"declineAllConflictingInvitations"`, or `"declineNone"`
- **`focusTimeProperties.declineMessage`** — optional message sent when
declining
### Out of Office
Signals unavailability and auto-declines conflicting meetings.
> **Constraint:** Out-of-office events **cannot be all-day events** — they must
> use `dateTime`, not `date`.
```
calendar.createEvent({
calendarId: "primary",
eventType: "outOfOffice",
summary: "Vacation",
start: { dateTime: "2025-01-15T00:00:00-05:00" },
end: { dateTime: "2025-01-19T00:00:00-05:00" },
outOfOfficeProperties: {
autoDeclineMode: "declineAllConflictingInvitations",
declineMessage: "I am on vacation until Jan 19"
}
})
```
- **`summary`** defaults to `"Out of Office"` if omitted
- **`outOfOfficeProperties.autoDeclineMode`** —
`"declineOnlyNewConflictingInvitations"` (default),
`"declineAllConflictingInvitations"`, or `"declineNone"`
- **`outOfOfficeProperties.declineMessage`** — optional message sent when
declining
### Working Location
Indicates where the user is working from. Supports both timed and all-day
events.
```
calendar.createEvent({
calendarId: "primary",
eventType: "workingLocation",
start: { date: "2025-01-15" },
end: { date: "2025-01-16" },
workingLocationProperties: {
type: "homeOffice"
}
})
```
- **`summary`** defaults to `"Working Location"` if omitted
- **All-day working location events** must span **exactly one day**. Use the
next day as the exclusive `end` date.
- **`workingLocationProperties`** is **required** when `eventType` is
`"workingLocation"`
- **`workingLocationProperties.type`** — `"homeOffice"`, `"officeLocation"`, or
`"customLocation"`
- **`officeLocation`** — `{ buildingId?: string, label?: string }` (when type is
`"officeLocation"`)
- **`customLocation`** — `{ label: string }` (when type is `"customLocation"`)
### Listing Events by Type
Use the `eventTypes` parameter on `calendar.listEvents` to filter by event type:
```
calendar.listEvents({
calendarId: "primary",
timeMin: "2025-01-15T00:00:00-05:00",
timeMax: "2025-01-17T23:59:59-05:00",
eventTypes: ["focusTime", "outOfOffice", "workingLocation"]
})
```
Available types: `"default"`, `"focusTime"`, `"outOfOffice"`,
`"workingLocation"`, `"birthday"`, `"fromGmail"`.
## Updating Events
Use `calendar.updateEvent` for modifications. Only the fields you provide will
be changed — everything else is preserved.
- **Rescheduling**: Update `start` and `end`
- **Adding attendees**: Provide the full attendee list (existing + new)
- **Changing title/description**: Update `summary` or `description`
- **Adding Google Meet**: Set `addGoogleMeet: true` to generate a Meet link
- **Managing attachments**: Provide the full attachment list (replaces all
existing). Pass `attachments: []` to clear all attachments.
> **Important:** The `attendees` field is a full replacement, not an append. To
> add a new attendee, include all existing attendees plus the new one. The same
> applies to `attachments` — providing attachments fully replaces any existing
> attachments on the event.
## Google Meet Integration
When creating or updating events, you can automatically generate a Google Meet
link by setting `addGoogleMeet: true`:
```
calendar.createEvent({
summary: "Team Standup",
start: { dateTime: "2025-01-15T10:00:00-05:00" },
end: { dateTime: "2025-01-15T10:30:00-05:00" },
addGoogleMeet: true
})
```
The Meet URL will be available in the response's `hangoutLink` field:
```json
{
"hangoutLink": "https://meet.google.com/abc-defg-hij",
"conferenceData": { ... }
}
```
## Google Drive Attachments
You can attach Google Drive files (Docs, Sheets, Slides, PDFs, etc.) to calendar
events:
```
calendar.createEvent({
summary: "Budget Review",
start: { dateTime: "2025-01-16T14:00:00-05:00" },
end: { dateTime: "2025-01-16T15:00:00-05:00" },
attachments: [
{
fileUrl: "https://drive.google.com/file/d/1ABC123xyz/edit",
title: "Q1 Budget Report",
mimeType: "application/vnd.google-apps.document"
}
]
})
```
**CRITICAL:** Attachments use **replacement semantics**, not append semantics.
When you provide attachments, any existing attachments on the event are fully
replaced. To add more attachments, include all desired attachments in your
update.
## Deleting Events
Use `calendar.deleteEvent` to remove an event. **This is a destructive action —
always confirm with the user before executing.**
| Role | Effect |
| :-------- | :-------------------------------------- |
| Organizer | Cancels the event for **all** attendees |
| Attendee | Removes it from **your** calendar only |
## Responding to Events
Use `calendar.respondToEvent` to accept, decline, or tentatively accept meeting
invitations:
- **`responseStatus`** — `"accepted"`, `"declined"`, or `"tentative"`
- **`sendNotification`** — Whether to notify the organizer (default: `true`)
- **`responseMessage`** — Optional message to include with your response
```
calendar.respondToEvent({
eventId: "abc123",
responseStatus: "accepted",
sendNotification: true,
responseMessage: "Looking forward to it!"
})
```
## Finding Free Time
Use `calendar.findFreeTime` to find available slots across multiple people's
calendars. This is ideal for scheduling new meetings.
- **`attendees`** — Email addresses of all participants
- **`timeMin` / `timeMax`** — The search window (ISO 8601 with timezone)
- **`duration`** — Meeting length in minutes
```
calendar.findFreeTime({
attendees: ["alice@example.com", "bob@example.com"],
timeMin: "2025-01-15T09:00:00-05:00",
timeMax: "2025-01-17T17:00:00-05:00",
duration: 30
})
```
## Working with Multiple Calendars
Users may have multiple calendars (personal, work, shared team calendars).
1. Use `calendar.list` to discover all available calendars
2. Pass the appropriate `calendarId` to other tools
3. If no `calendarId` is provided, tools default to the **primary** calendar
## Tool Quick Reference
| Tool | Action | Key Parameters |
| :------------------------ | :------------------------------- | :----------------------------------------------------------------------------------- |
| `calendar.list` | List all calendars | _(none)_ |
| `calendar.listEvents` | List events (filterable by type) | `calendarId`, `timeMin`, `timeMax`, `eventTypes` |
| `calendar.getEvent` | Get event details | `eventId`, `calendarId` |
| `calendar.createEvent` | Create event (all types) | `calendarId`, `summary`, `start`, `end`, `eventType`, `addGoogleMeet`, `attachments` |
| `calendar.updateEvent` | Modify an existing event | `eventId`, `summary`, `start`, `end`, `attendees`, `addGoogleMeet`, `attachments` |
| `calendar.deleteEvent` | Delete an event | `eventId`, `calendarId` |
| `calendar.respondToEvent` | Accept/decline an invite | `eventId`, `responseStatus` |
| `calendar.findFreeTime` | Find available meeting time | `attendees`, `timeMin`, `timeMax`, `duration` |
================================================
FILE: skills/google-chat/SKILL.md
================================================
---
name: google-chat
description: >
CRITICAL: You MUST activate this skill BEFORE sending, reading, or managing
Google Chat messages. Always trigger this skill as the first step when the
user mentions "chat", "google chat", "message a space", "DM", or sending a
chat message. Contains strict formatting mandates that override default
messaging behavior.
---
# Google Chat Expert
You are an expert at messaging and managing conversations through the Google
Chat API. Follow these guidelines when helping users with chat tasks.
## Chat Message Formatting
When composing messages (via `chat.sendMessage` or `chat.sendDm`), **always use
Google Chat's supported markdown syntax**. Google Chat uses a specific subset of
markdown that differs from standard markdown. You MUST convert any unsupported
syntax before sending.
### Supported Formatting
| Syntax | Renders As | Example |
| :----------------- | :---------------- | :----------------------------- |
| `*text*` | **bold** | `*Important update*` |
| `_text_` | _italic_ | `_Please review_` |
| `~text~` | ~~strikethrough~~ | `~no longer relevant~` |
| `` `code` `` | `inline code` | `` `git status` `` |
| ` ``` ` | code block | ` ```\ncode\n``` ` |
| `* ` or `- ` | bulleted list | `* Item one\n* Item two` |
| `<url\|text>` | hyperlink | `<https://example.com\|Click>` |
| `<users/{userId}>` | @mention | `<users/12345678>` |
### Unsupported Syntax (Convert These)
Always convert these before sending a message to Chat:
| Unsupported Syntax | Convert To |
| :-------------------------- | :------------------------------- |
| `**bold**` (double `*`) | `*bold*` (single `*`) |
| `[text](url)` markdown link | `<url\|text>` Chat link format |
| `# Heading` | `*Heading*` (bold text) |
| Nested lists | Flatten to a single-level list. |
| `> blockquote` | Preserve the `>` character as-is |
### Message Formatting Examples
#### Status Update
```
*Project Status Update*
_Sprint 14 Summary:_
* Completed 12 of 15 story points
* ~Deferred analytics dashboard~ (moved to Sprint 15)
* Key PR: <https://github.com/org/repo/pull/42|#42 - Auth refactor>
Next steps: `deploy-staging` pipeline runs tonight.
```
#### Code Snippet
````
Found the bug. The issue is in the handler:
```
func handleRequest(ctx context.Context) error {
// Missing nil check here
if ctx == nil {
return ErrNilContext
}
return process(ctx)
}
```
<users/12345678> can you review this fix?
````
## Spaces vs. Direct Messages
Google Chat has two main messaging contexts. Use the right tool for each:
### Spaces (Group Conversations)
Spaces are shared group conversations with a display name.
| Action | Tool | Key Parameter |
| :----------------- | :--------------------- | :------------------------- |
| Find a space | `chat.findSpaceByName` | `displayName` |
| List all spaces | `chat.listSpaces` | _(none)_ |
| Send a message | `chat.sendMessage` | `spaceName`, `message` |
| Create a new space | `chat.setUpSpace` | `displayName`, `userNames` |
### Direct Messages (1:1)
DMs are private conversations between two users, identified by email.
| Action | Tool | Key Parameter |
| :-------------- | :------------------- | :----------------- |
| Find a DM space | `chat.findDmByEmail` | `email` |
| Send a DM | `chat.sendDm` | `email`, `message` |
> **Note:** `chat.sendDm` and `chat.findDmByEmail` both use `spaces.setup` under
> the hood. If no DM space exists with the user, one is automatically created.
> There is no need to create a DM space separately.
> **Limitation:** DM tools only support 1:1 conversations. For group
> conversations (3+ people), use `chat.setUpSpace` to create a named space
> instead.
## Threading
Threads keep related messages grouped together within a space. Use the
`threadName` parameter to reply in an existing thread.
### How Threading Works
1. **Start a new thread**: Send a message without `threadName`. The response
will include a `thread.name` you can use for replies.
2. **Reply to a thread**: Pass `threadName` when calling `chat.sendMessage` or
`chat.sendDm`. The API uses `REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` — if the
thread doesn't exist, a new thread is created.
3. **List threads**: Use `chat.listThreads` to discover threads in a space. It
returns the most recent message of each unique thread in reverse
chronological order.
4. **Get thread messages**: Use `chat.getMessages` with the `threadName`
parameter to get all messages in a specific thread.
### Thread Example Workflow
```
1. chat.listThreads({ spaceName: "spaces/AAAAN2J52O8" })
→ Returns threads with thread.name values
2. chat.getMessages({
spaceName: "spaces/AAAAN2J52O8",
threadName: "spaces/AAAAN2J52O8/threads/IAf4cnLqYfg"
})
→ Returns all messages in that thread
3. chat.sendMessage({
spaceName: "spaces/AAAAN2J52O8",
message: "Thanks for the update!",
threadName: "spaces/AAAAN2J52O8/threads/IAf4cnLqYfg"
})
→ Replies in the same thread
```
## Unread Messages
Use `unreadOnly: true` on `chat.getMessages` to filter for unread messages only.
### How It Works
The unread filter:
1. Looks up the authenticated user's ID via the People API
2. Finds the user's membership in the space
3. Uses the `lastReadTime` from the membership to filter messages created after
that timestamp
4. If no `lastReadTime` is found, all messages are returned (treats everything
as unread)
### Combining Filters
You can combine `unreadOnly` with `threadName` to get unread messages in a
specific thread. The filters are joined with `AND`:
```
chat.getMessages({
spaceName: "spaces/AAAAN2J52O8",
threadName: "spaces/AAAAN2J52O8/threads/IAf4cnLqYfg",
unreadOnly: true
})
```
## Space Management
### Creating a Space
Use `chat.setUpSpace` to create a new named space with members:
```
chat.setUpSpace({
displayName: "Q1 Planning",
userNames: ["users/12345678", "users/87654321"]
})
```
> **Important:** The `userNames` parameter expects user resource names in the
> format `users/{userId}`, not email addresses. Use `people.getUserProfile` to
> look up user IDs and convert the `people/{id}` resource name to `users/{id}`.
## Resource Name Formats
Google Chat uses structured resource names. Here is a quick reference:
| Resource | Format | Example |
| :------- | :-------------------------------------- | :--------------------------------------- |
| Space | `spaces/{spaceId}` | `spaces/AAAAN2J52O8` |
| Message | `spaces/{spaceId}/messages/{messageId}` | `spaces/AAAAN2J52O8/messages/abc123` |
| Thread | `spaces/{spaceId}/threads/{threadId}` | `spaces/AAAAN2J52O8/threads/IAf4cnLqYfg` |
| User | `users/{userId}` | `users/12345678` |
================================================
FILE: skills/google-docs/SKILL.md
================================================
---
name: google-docs
description: >
CRITICAL: You MUST activate this skill BEFORE creating, editing, or managing
Google Docs. Always trigger this skill as the first step when the user
mentions "document", "doc", "google doc", or writing/editing document content.
Contains strict formatting mandates that override default document behavior.
---
# Google Docs Expert
You are an expert at creating and managing documents through the Google Docs
API. Follow these guidelines when helping users with document tasks.
## Document Formatting — Two-Step Workflow
To create richly formatted documents, use a **two-step process**:
1. **Insert content** using `docs.create` or `docs.writeText` — this inserts
plain text
2. **Apply formatting** using `docs.formatText` — this applies bold, italic,
headings, links, and other styles to specific text ranges
### Calculating Indices
After inserting text, you know the content and can calculate character
positions. Indices are 1-based (index 1 is the start of the document body).
For example, if you insert:
```
Project Update\n\nStatus: On Track\n
```
Then:
- "Project Update" spans indices 1–15
- "Status: On Track" spans indices 17–33
### Supported Formatting Styles
| Style | Effect | API |
| --------------- | ------------------------------- | -------------------- |
| `bold` | **Bold text** | updateTextStyle |
| `italic` | _Italic text_ | updateTextStyle |
| `underline` | Underlined text | updateTextStyle |
| `strikethrough` | ~~Strikethrough text~~ | updateTextStyle |
| `code` | `Monospace font` (Courier New) | updateTextStyle |
| `link` | Hyperlink (requires `url`) | updateTextStyle |
| `heading1` | Heading 1 | updateParagraphStyle |
| `heading2` | Heading 2 | updateParagraphStyle |
| `heading3` | Heading 3 | updateParagraphStyle |
| `heading4` | Heading 4 | updateParagraphStyle |
| `heading5` | Heading 5 | updateParagraphStyle |
| `heading6` | Heading 6 | updateParagraphStyle |
| `normalText` | Reset to normal paragraph style | updateParagraphStyle |
### Formatting Example
Create a doc with a heading and bold text:
```
// Step 1: Create with content
docs.create({
title: "Weekly Report",
content: "Weekly Report\n\nHighlights\n\n- Revenue up 12%\n- 3 new launches\n"
})
// Step 2: Apply formatting
docs.formatText({
documentId: "<id-from-step-1>",
formats: [
{ startIndex: 1, endIndex: 14, style: "heading1" },
{ startIndex: 16, endIndex: 26, style: "heading2" },
{ startIndex: 16, endIndex: 26, style: "bold" }
]
})
```
### Professional Document Structure
When creating documents, use a clear heading hierarchy:
- **Heading 1** — Document title (use once, at the top)
- **Heading 2** — Major sections
- **Heading 3** — Subsections within a section
- **Bold** — Labels, field names, and emphasis within body text
**Structure the content first, then apply formatting generously.** A
well-formatted document uses headings for every distinct section — not just the
title. Think of each logical group of content as deserving its own heading.
#### Example: PR Summary Document
**Step 1 — Content:**
```
PR Summary Report
PR #246: Add Gmail Skill
Author: Allen Hutchison
Status: Merged
This PR introduces a new agent skill for Gmail with rich HTML formatting
guidance, establishing the skills architecture for the extension.
Key Changes:
- Added skills/gmail/SKILL.md with email formatting instructions
- Updated WORKSPACE-Context.md to cross-reference the new skill
- Modified release script to bundle the skills directory
PR #245: Bump rollup from 4.57.1 to 4.59.0
Author: dependabot
Status: Merged
Routine dependency update for the build pipeline.
```
**Step 2 — Formatting:**
```
docs.formatText({
documentId: "<id>",
formats: [
// Document title
{ startIndex: 1, endIndex: 18, style: "heading1" },
// PR section headings
{ startIndex: 20, endIndex: 45, style: "heading2" },
{ startIndex: 287, endIndex: 325, style: "heading2" },
// Field labels
{ startIndex: 47, endIndex: 54, style: "bold" },
{ startIndex: 73, endIndex: 80, style: "bold" },
{ startIndex: 89, endIndex: 101, style: "bold" },
{ startIndex: 327, endIndex: 334, style: "bold" },
{ startIndex: 347, endIndex: 354, style: "bold" },
]
})
```
### Formatting Best Practices
1. **Always insert text first**, then apply formatting — formatting operates on
existing text ranges
2. **Calculate indices carefully** — count characters including newlines (`\n`)
3. **Heading styles apply to the entire paragraph** — even if the range covers
only part of it
4. **Multiple styles can stack** — apply both `heading2` and `bold` to the same
range for bold headings
5. **Use links for URLs** — apply `link` style with a `url` field instead of
pasting raw URLs
6. **Format generously** — use heading2 for every major section, bold for every
label or field name. A document with only a heading1 title and plain text
body looks unprofessional
## Creating Documents
Use `docs.create` to create new documents:
- **Blank document**: Provide only a `title`
- **Document with content**: Provide `title` and `content` — the content is
inserted into the document after creation
- **In a specific folder**: Add `folderName` to organize the document
```
docs.create({
title: "Weekly Status Report",
content: "Status Report - Week of March 10\n\nHighlights\n\n- ...",
folderName: "Team Reports"
})
```
## Writing Text
Use `docs.writeText` to add text to an existing document:
- **Append to end** (default): `position: "end"` or omit position
- **Insert at beginning**: `position: "beginning"`
- **Insert at specific index**: `position: "5"` (numeric string)
```
docs.writeText({
documentId: "doc-id",
text: "New content\n",
position: "end"
})
```
## Find and Replace
Use `docs.replaceText` to find all occurrences of a string and replace them.
This works across all tabs by default, or in a specific tab with `tabId`.
## Tab Management
Google Docs supports multiple tabs within a single document.
### Reading Tabs
- **Single tab**: `docs.getText` returns plain text directly
- **Multiple tabs**: Returns JSON array with `tabId`, `title`, `content`, and
`index` for each tab
- **Specific tab**: Pass `tabId` to read only that tab
- **Nested tabs**: Child tabs are flattened and included in results
### Writing to Tabs
All write tools (`writeText`, `replaceText`, `formatText`) accept an optional
`tabId` parameter:
- **Without `tabId`**: Operates on the first tab (default)
- **With `tabId`**: Operates on the specified tab, including nested child tabs
## Document Organization
### Finding Documents
Use `drive.search` with a document MIME type filter to find Google Docs:
```
drive.search({
query: "mimeType='application/vnd.google-apps.document' and name contains 'Weekly Report'"
})
```
For full-text search across document content, use `fullText contains` instead of
`name contains`.
### Moving Documents
Use `drive.moveFile` to move a document to a different folder. You can specify
the destination by folder ID or folder name.
## Comments & Suggestions
### Reading Comments
Use `drive.getComments` to retrieve all comments on a document:
- Returns comment threads with author, content, timestamp, and resolution status
- Includes **threaded replies** with author, content, timestamp, and action
(e.g., `resolve`, `reopen`)
- Includes **quoted file content** showing what text the comment is anchored to
```
drive.getComments({ fileId: "doc-id" })
```
### Reading Suggestions
Use `docs.getSuggestions` to retrieve suggested edits from a document:
- **Insertions** — text proposed for addition (`suggestedInsertionIds`)
- **Deletions** — text proposed for removal (`suggestedDeletionIds`)
- **Style changes** — text formatting changes (bold, italic, etc.)
- **Paragraph style changes** — heading level changes (e.g., NORMAL_TEXT →
HEADING_2)
Each suggestion includes the affected text, suggestion IDs, and start/end
indices.
```
docs.getSuggestions({ documentId: "doc-id" })
```
## ID Handling
- All tools accept Google Drive URLs directly — no manual ID extraction needed
- IDs and URLs are interchangeable in all `documentId` parameters
================================================
FILE: skills/google-sheets/SKILL.md
================================================
---
name: google-sheets
description: >
Activate this skill when the user wants to find, read, or analyze Google
Sheets spreadsheets. Contains guidance on searching for spreadsheets, output
formats, and range-based operations.
---
# Google Sheets Expert
You are an expert at working with Google Sheets spreadsheets through the
Workspace Extension tools. Follow these guidelines when helping users with
spreadsheet tasks.
## Finding Spreadsheets
Use `drive.search` with a Sheets MIME type filter to find spreadsheets:
```
drive.search({
query: "mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Budget'"
})
```
For full-text search across spreadsheet content, use `fullText contains` instead
of `name contains`.
## Reading Data
### Full Spreadsheet
Use `sheets.getText` to read all sheets in a spreadsheet. Choose the output
format based on the use case:
- **text** (default): Human-readable with pipe-separated columns — good for
quick review
- **csv**: Standard CSV format — good for data export and analysis
- **json**: Structured JSON keyed by sheet name — good for programmatic
processing
### Specific Range
Use `sheets.getRange` with A1 notation to read a specific cell range:
```
sheets.getRange({
spreadsheetId: "spreadsheet-id",
range: "Sheet1!A1:D10"
})
```
### Metadata
Use `sheets.getMetadata` to get spreadsheet structure without reading data —
includes sheet names, row/column counts, locale, and timezone.
## ID Handling
- All tools accept Google Drive URLs directly — no manual ID extraction needed
- IDs and URLs are interchangeable in all `spreadsheetId` parameters
================================================
FILE: skills/google-slides/SKILL.md
================================================
---
name: google-slides
description: >
Activate this skill when the user wants to find, read, or extract content from
Google Slides presentations. Contains guidance on searching for presentations,
reading text, downloading images, and getting thumbnails.
---
# Google Slides Expert
You are an expert at working with Google Slides presentations through the
Workspace Extension tools. Follow these guidelines when helping users with
presentation tasks.
## Finding Presentations
Use `drive.search` with a Slides MIME type filter to find presentations:
```
drive.search({
query: "mimeType='application/vnd.google-apps.presentation' and name contains 'Quarterly Review'"
})
```
For full-text search across presentation content, use `fullText contains`
instead of `name contains`.
## Reading Content
### Text Extraction
Use `slides.getText` to extract all text content from a presentation. Text is
organized by slide with clear separators.
### Metadata
Use `slides.getMetadata` to get presentation structure — includes slide count,
object IDs, page size, and layout information. Slide object IDs from metadata
can be used with `slides.getSlideThumbnail`.
## Downloading Images
### All Images
Use `slides.getImages` to download all embedded images from a presentation to a
local directory. Requires an **absolute path** for the output directory.
### Slide Thumbnails
Use `slides.getSlideThumbnail` to download a thumbnail of a specific slide.
Requires the slide's `objectId` (from `slides.getMetadata` or `slides.getText`)
and an **absolute path** for the output file.
## ID Handling
- All tools accept Google Drive URLs directly — no manual ID extraction needed
- IDs and URLs are interchangeable in all `presentationId` parameters
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["workspace-server/src/**/*"],
"exclude": [
"node_modules",
"**/node_modules",
"**/dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
================================================
FILE: workspace-server/.github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: workspace-mcp-server/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: workspace-mcp-server
- name: Run linter
run: npm run lint --if-present
working-directory: workspace-mcp-server
- name: Run type checking
run: npx tsc --noEmit
working-directory: workspace-mcp-server
- name: Run tests
run: npm test
working-directory: workspace-mcp-server
- name: Generate coverage report
run: npm run test:coverage
working-directory: workspace-mcp-server
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
directory: ./workspace-mcp-server/coverage
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: workspace-mcp-server/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: workspace-mcp-server
- name: Build
run: npm run build
working-directory: workspace-mcp-server
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: workspace-mcp-server/dist/
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run security audit
run: npm audit --audit-level=moderate
working-directory: workspace-mcp-server
continue-on-error: true
- name: Check for known vulnerabilities
run: npx audit-ci --moderate
working-directory: workspace-mcp-server
continue-on-error: true
================================================
FILE: workspace-server/.github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: workspace-mcp-server/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: workspace-mcp-server
- name: Run tests
run: npm test
working-directory: workspace-mcp-server
- name: Build
run: npm run build
working-directory: workspace-mcp-server
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./workspace-mcp-server/dist/index.js
asset_name: workspace-mcp-server.js
asset_content_type: application/javascript
================================================
FILE: workspace-server/WORKSPACE-Context.md
================================================
# Google Workspace Extension - Behavioral Guide
This guide provides behavioral instructions for effectively using the Google
Workspace Extension tools. For detailed parameter documentation, refer to the
tool descriptions in the extension itself.
## 🎯 Core Principles
### 1. User Context First
**Always establish user context at the beginning of interactions:**
- Use `people.getMe()` to understand who the user is
- Use `time.getTimeZone()` to get the user's local timezone
- Apply this context throughout all interactions
- All time-based operations should respect the user's timezone
### 2. Safety and Transparency
**Never execute write operations without explicit confirmation:**
- Preview all changes before executing
- Show complete details in a readable format
- Wait for clear user approval
- Give users the opportunity to review and cancel
### 3. Smart Tool Usage
**Choose the right approach for each task:**
- Tools automatically handle URL-to-ID conversion - don't extract IDs manually
- Batch related operations when possible
- Use pagination for large result sets
- Apply appropriate formats based on the use case
## 📋 Output Formatting Standards
### Lists and Search Results
Always format multiple items as **numbered lists** for better readability:
✅ **Correct:**
```
Found 3 documents:
1. Budget Report 2024
2. Q3 Sales Presentation
3. Team Meeting Notes
```
❌ **Incorrect:**
```
Found 3 documents:
- Budget Report 2024
- Q3 Sales Presentation
- Team Meeting Notes
```
### Write Operation Previews
Before any write operation, show a clear preview:
```
I'll create this calendar event:
Title: Team Standup
Date: January 15, 2025
Time: 10:00 AM - 10:30 AM (EST)
Attendees: team@example.com
Should I create this event?
```
## 🔄 Multi-Tool Workflows
### Creating and Organizing Documents
When creating documents in specific folders:
1. Create the document with `docs.create` (blank)
2. Move it to the target folder with `drive.moveFile`
3. Confirm successful completion
To find Google Docs, Sheets, or Slides, use `drive.search` with a MIME type
filter rather than searching by name alone. Example MIME type queries:
- Docs:
`mimeType='application/vnd.google-apps.document' and name contains 'query'`
- Sheets:
`mimeType='application/vnd.google-apps.spreadsheet' and name contains 'query'`
- Slides:
`mimeType='application/vnd.google-apps.presentation' and name contains 'query'`
## 🚫 Common Pitfalls to Avoid
### Don't Do This:
- ❌ Use `extractIdFromUrl` when other tools accept URLs
- ❌ Assume timezone without checking
- ❌ Execute writes without preview and confirmation
- ❌ Create files unless explicitly requested
- ❌ Duplicate parameter documentation from tool descriptions
- ❌ Use relative paths for file downloads (e.g., `downloads/file.txt`)
### Do This Instead:
- ✅ Pass URLs directly to tools that accept them
- ✅ Get user timezone at session start
- ✅ Preview all changes and wait for approval
- ✅ Only create what's requested
- ✅ Focus on behavioral guidance and best practices
- ✅ Always use **absolute paths** for file downloads (e.g.,
`/Users/me/Downloads/file.txt`)
## 🔍 Error Handling Patterns
### Authentication Errors
- If any tool returns `{"error":"invalid_request"}`, it likely indicates an
expired or invalid session.
- **Action:** Call `auth.clear` to reset credentials and force a re-login.
- Inform the user that you are resetting authentication due to an error.
### Graceful Degradation
- If a folder doesn't exist, offer to create it
- If search returns no results, suggest alternatives
- If permissions are insufficient, explain clearly
### Validation Before Action
- Verify file/folder existence before moving
- Check calendar availability before scheduling
- Validate email addresses before sending
## ⚡ Performance Optimization
### Batch Operations
- Group related API calls when possible
- Use field masks to request only needed data
- Implement pagination for large datasets
### Caching Strategy
- Reuse user context throughout session
- Cache frequently accessed metadata
- Minimize redundant API calls
## 📝 Session Management
### Beginning of Session
1. Get user profile with `people.getMe()`
2. Get timezone with `time.getTimeZone()`
3. Establish any relevant context
### During Interaction
- Maintain context awareness
- Apply user preferences consistently
- Handle follow-up questions efficiently
### End of Session
- Confirm all requested tasks completed
- Provide summary if multiple operations performed
- Ensure no pending confirmations
## 🎨 Service-Specific Nuances
### Google Docs
- See the **Google Docs skill** for detailed guidance on document content
formatting, creation, editing, tab management, and document organization.
### Google Sheets
- See the **Google Sheets skill** for detailed guidance on finding spreadsheets,
output format selection, and range-based operations.
### Google Slides
- See the **Google Slides skill** for detailed guidance on finding
presentations, text extraction, image downloads, and slide thumbnails.
### Google Calendar
- See the **Google Calendar skill** for detailed guidance on timezone handling,
meeting queries, event management, responding to invitations, and scheduling.
### Gmail
- See the **Gmail skill** for detailed guidance on composing rich HTML emails,
search syntax, label management, attachments, and threading.
### Google Chat
- See the **Google Chat skill** for detailed guidance on formatting messages,
spaces vs. DMs, threading, unread filtering, and space management.
Remember: This guide focuses on **how to think** about using these tools
effectively. For specific parameter details, refer to the tool descriptions
themselves.
================================================
FILE: workspace-server/esbuild.auth-utils.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const esbuild = require('esbuild');
const path = require('node:path');
async function buildAuthUtils() {
try {
await esbuild.build({
entryPoints: ['src/auth/token-storage/oauth-credential-storage.ts'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: 'dist/auth-utils.js',
minify: true,
sourcemap: true,
external: [
'keytar', // keytar is a native module and should not be bundled
],
format: 'cjs',
logLevel: 'info',
});
console.log('Auth Utils build completed successfully!');
} catch (error) {
console.error('Auth Utils build failed:', error);
process.exit(1);
}
}
buildAuthUtils();
================================================
FILE: workspace-server/esbuild.config.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const esbuild = require('esbuild');
const path = require('node:path');
const fs = require('node:fs');
async function build() {
try {
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
target: 'node16',
outfile: 'dist/index.js',
minify: true,
sourcemap: true,
// Replace 'open' package with our wrapper
alias: {
open: path.resolve(__dirname, 'src/utils/open-wrapper.ts'),
},
// External packages that shouldn't be bundled
external: [],
// Add a loader for .node files
loader: {
'.node': 'file',
},
// Make sure CommonJS modules work properly
format: 'cjs',
logLevel: 'info',
});
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
build();
================================================
FILE: workspace-server/esbuild.headless-login.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const esbuild = require('esbuild');
async function buildHeadlessLogin() {
try {
await esbuild.build({
entryPoints: ['src/cli/headless-login.ts'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: 'dist/headless-login.js',
minify: true,
sourcemap: true,
external: [
'keytar', // keytar is a native module and should not be bundled
],
format: 'cjs',
logLevel: 'info',
});
console.log('Headless Login build completed successfully!');
} catch (error) {
console.error('Headless Login build failed:', error);
process.exit(1);
}
}
buildHeadlessLogin();
================================================
FILE: workspace-server/jest.config.js
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @type {import('jest').Config} */
module.exports = {
// This workspace's tests are configured in the root jest.config.js
// as part of the 'projects' array. This file is kept for backwards
// compatibility and workspace-specific overrides if needed.
};
================================================
FILE: workspace-server/package.json
================================================
{
"name": "workspace-server",
"version": "0.0.8",
"description": "",
"main": "dist/index.js",
"scripts": {
"test": "cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --runInBand --verbose",
"test:watch": "cd .. && jest --watch",
"test:coverage": "cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --coverage",
"test:ci": "cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2",
"start": "ts-node src/index.ts",
"build": "node esbuild.config.js && node esbuild.headless-login.js",
"build:auth-utils": "node esbuild.auth-utils.js",
"build:headless-login": "node esbuild.headless-login.js"
},
"keywords": [],
"author": "Allen Hutchison",
"license": "Apache-2.0",
"type": "commonjs",
"devDependencies": {
"esbuild": "^0.28.0"
}
}
================================================
FILE: workspace-server/src/__tests__/auth/AuthManager.test.ts
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthManager } from '../../auth/AuthManager';
import { OAuthCredentialStorage } from '../../auth/token-storage/oauth-credential-storage';
import { google } from 'googleapis';
// Mock dependencies
jest.mock('../../auth/token-storage/oauth-credential-storage');
jest.mock('googleapis');
jest.mock('../../utils/logger');
jest.mock('../../utils/secure-browser-launcher');
// Mock fetch globally for refreshToken tests
global.fetch = jest.fn();
describe('AuthManager', () => {
let authManager: AuthManager;
let mockOAuth2Client: any;
beforeEach(() => {
jest.clearAllMocks();
// Setup mock OAuth2 client
mockOAuth2Client = {
setCredentials: jest.fn().mockImplementation((creds) => {
mockOAuth2Client.credentials = creds;
}),
generateAuthUrl: jest.fn(),
on: jest.fn(),
refreshAccessToken: jest.fn(),
credentials: {},
};
(google.auth.OAuth2 as unknown as jest.Mock).mockReturnValue(
mockOAuth2Client,
);
authManager = new AuthManager(['scope1']);
});
it('should set up tokens event listener on client creation', async () => {
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'old_token',
refresh_token: 'old_refresh',
scope: 'scope1',
});
await authManager.getAuthenticatedClient();
// Verify 'on' was called for 'tokens'
expect(mockOAuth2Client.on).toHaveBeenCalledWith(
'tokens',
expect.any(Function),
);
});
it('should save credentials when tokens event is emitted', async () => {
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'old_token',
refresh_token: 'old_refresh',
scope: 'scope1',
});
await authManager.getAuthenticatedClient();
// Get the registered callback
const tokensCallback = mockOAuth2Client.on.mock.calls.find(
(call: any[]) => call[0] === 'tokens',
)[1];
expect(tokensCallback).toBeDefined();
// Simulate tokens event
const newTokens = {
access_token: 'new_token',
expiry_date: 123456789,
};
await tokensCallback(newTokens);
// Verify saveCredentials was called with merged tokens
// New tokens take precedence, but refresh_token is preserved from old credentials
expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({
access_token: 'new_token',
refresh_token: 'old_refresh', // Preserved from old credentials
expiry_date: 123456789,
// Note: scope is NOT preserved because newTokens didn't include it
});
});
it('should preserve refresh token during manual refresh if not returned', async () => {
// Setup initial state with a refresh token
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'old_token',
refresh_token: 'old_refresh_token',
scope: 'scope1',
});
// Initialize client to populate this.client
await authManager.getAuthenticatedClient();
// Mock fetch to simulate cloud function returning new tokens without refresh_token
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
expiry_date: 999999999,
}),
});
await authManager.refreshToken();
// Verify saveCredentials was called with BOTH new access token AND old refresh token
expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new_access_token',
refresh_token: 'old_refresh_token',
}),
);
});
it('should preserve refresh token when refreshAccessToken mutates credentials in-place', async () => {
// Setup initial state with a refresh token
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'old_token',
refresh_token: 'old_refresh_token',
scope: 'scope1',
});
// Initialize client to populate this.client
await authManager.getAuthenticatedClient();
// Mock fetch to simulate cloud function returning new tokens without refresh_token
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
expiry_date: 999999999,
}),
});
await authManager.refreshToken();
// This test verifies that the refresh_token is preserved even when
// the cloud function doesn't return it in the response
expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new_access_token',
refresh_token: 'old_refresh_token',
}),
);
});
it('should preserve refresh token in tokens event handler', async () => {
// Setup initial state with a refresh token in storage
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'old_token',
refresh_token: 'stored_refresh_token',
scope: 'scope1',
});
await authManager.getAuthenticatedClient();
// Get the registered callback
const tokensCallback = mockOAuth2Client.on.mock.calls.find(
(call: any[]) => call[0] === 'tokens',
)[1];
// Simulate automatic refresh that doesn't include refresh_token
const newTokens = {
access_token: 'auto_refreshed_token',
expiry_date: 999999999,
// Note: no refresh_token
};
await tokensCallback(newTokens);
// Verify saveCredentials was called with BOTH new access token AND stored refresh token
expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({
access_token: 'auto_refreshed_token',
expiry_date: 999999999,
refresh_token: 'stored_refresh_token',
});
});
it('should proactively refresh expired tokens before returning client', async () => {
// Setup: Load credentials with expired token
const expiredTime = Date.now() - 1000; // 1 second ago
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'expired_token',
refresh_token: 'valid_refresh',
expiry_date: expiredTime,
scope: 'scope1',
});
// Mock fetch to simulate cloud function returning fresh tokens
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'fresh_token',
expiry_date: Date.now() + 3600000,
}),
});
// First call: load expired credentials from storage, should trigger proactive refresh
const firstClient = await authManager.getAuthenticatedClient();
expect(firstClient).toBeDefined();
// Verify fetch was called to refresh the token
expect(global.fetch).toHaveBeenCalledWith(
'https://google-workspace-extension.geminicli.com/refreshToken',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('valid_refresh'),
}),
);
// Verify new token was saved with preserved refresh_token
expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'fresh_token',
refresh_token: 'valid_refresh',
}),
);
});
it('should proactively refresh tokens expiring within buffer (5 minutes)', async () => {
// Setup: Load credentials with token expiring in 4 minutes (within 5 min buffer)
const TEST_EXPIRY_WITHIN_BUFFER = 4 * 60 * 1000;
const expiresIn4Minutes = Date.now() + TEST_EXPIRY_WITHIN_BUFFER;
(OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({
access_token: 'soon_expiring_token',
refresh_token: 'valid_refresh',
expiry_date: expiresIn4Minutes,
scope: 'scope1',
});
// Mock fetch to simulate cloud function returning fresh tokens
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'fresh_token',
expiry_date: Date.now() + 60 * 60 * 1000,
}),
});
// Call getAuthenticatedClient
const client = await authManager.getAuthenticatedClient();
expect(client).toBeDefined();
// Verify fetch was called to refresh the token because it was within buffer
expect(global.fetch).toHaveBeenCalledWith(
'https://google-workspace-extension.geminicli.com/refreshToken',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('valid_refresh'),
}),
);
});
});
================================================
FILE: workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { BaseTokenStorage } from '../../../auth/token-storage/base-token-storage';
import type {
OAuthCredentials,
OAuthToken,
} from '../../../auth/token-storage/types';
class TestTokenStorage extends BaseTokenStorage {
private storage = new Map<string, OAuthCredentials>();
async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
return this.storage.get(serverName) || null;
}
async setCredentials(credentials: OAuthCredentials): Promise<void> {
this.validateCredentials(credentials);
this.storage.set(credentials.serverName, credentials);
}
async deleteCredentials(serverName: string): Promise<void> {
this.storage.delete(serverName);
}
async listServers(): Promise<string[]> {
return Array.from(this.storage.keys());
}
async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
return new Map(this.storage);
}
async clearAll(): Promise<void> {
this.storage.clear();
}
override validateCredentials(credentials: OAuthCredentials): void {
super.validateCredentials(credentials);
}
override sanitizeServerName(serverName: string): string {
return super.sanitizeServerName(serverName);
}
}
describe('BaseTokenStorage', () => {
let storage: TestTokenStorage;
beforeEach(() => {
storage = new TestTokenStorage('gemini-cli-mcp-oauth');
});
describe('validateCredentials', () => {
it('should validate valid credentials with access token', () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
expect(() => storage.validateCredentials(credentials)).not.toThrow();
});
it('should validate valid credentials with refresh token', () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
refreshToken: 'refresh-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
expect(() => storage.validateCredentials(credentials)).not.toThrow();
});
it('should throw for missing server name', () => {
const credentials = {
serverName: '',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
} as OAuthCredentials;
expect(() => storage.validateCredentials(credentials)).toThrow(
'Server name is required',
);
});
it('should throw for missing token', () => {
const credentials = {
serverName: 'test-server',
token: null as unknown as OAuthToken,
updatedAt: Date.now(),
} as OAuthCredentials;
expect(() => storage.validateCredentials(credentials)).toThrow(
'Token is required',
);
});
it('should throw for missing access token and refresh token', () => {
const credentials = {
serverName: 'test-server',
token: {
accessToken: '',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
} as OAuthCredentials;
expect(() => storage.validateCredentials(credentials)).toThrow(
'Access token or refresh token is required',
);
});
it('should throw for missing token type', () => {
const credentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: '',
},
updatedAt: Date.now(),
} as OAuthCredentials;
expect(() => storage.validateCredentials(credentials)).toThrow(
'Token type is required',
);
});
});
describe('sanitizeServerName', () => {
it('should keep valid characters', () => {
expect(storage.sanitizeServerName('test-server.example_123')).toBe(
'test-server.example_123',
);
});
it('should replace invalid characters with underscore', () => {
expect(storage.sanitizeServerName('test@server#example')).toBe(
'test_server_example',
);
});
it('should handle special characters', () => {
expect(storage.sanitizeServerName('test server/example:123')).toBe(
'test_server_example_123',
);
});
});
});
================================================
FILE: workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts
================================================
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
jest,
} from '@jest/globals';
import * as crypto from 'node:crypto';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { FileTokenStorage } from '../../../auth/token-storage/file-token-storage';
import type { OAuthCredentials } from '../../../auth/token-storage/types';
import {
ENCRYPTED_TOKEN_PATH,
ENCRYPTION_MASTER_KEY_PATH,
} from '../../../utils/paths';
jest.mock('node:fs', () => ({
promises: {
readFile: jest.fn(),
writeFile: jest.fn(),
unlink: jest.fn(),
mkdir: jest.fn(),
},
existsSync: jest.fn(() => true),
}));
jest.mock('node:os', () => ({
default: {
homedir: jest.fn(() => '/home/test'),
hostname: jest.fn(() => 'test-host'),
userInfo: jest.fn(() => ({ username: 'test-user' })),
},
homedir: jest.fn(() => '/home/test'),
hostname: jest.fn(() => 'test-host'),
userInfo: jest.fn(() => ({ username: 'test-user' })),
}));
describe('FileTokenStorage', () => {
let storage: FileTokenStorage;
const mockFs = fs as unknown as {
readFile: ReturnType<typeof jest.fn>;
writeFile: ReturnType<typeof jest.fn>;
unlink: ReturnType<typeof jest.fn>;
mkdir: ReturnType<typeof jest.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
token: {
accessToken: 'existing-token',
tokenType: 'Bearer',
},
updatedAt: Date.now() - 10000,
};
afterEach(() => {
jest.clearAllMocks();
});
describe('when master key does not exist', () => {
it('should create a new master key', async () => {
const error = new Error('File not found');
(error as NodeJS.ErrnoException).code = 'ENOENT';
mockFs.readFile.mockRejectedValue(error);
storage = await FileTokenStorage.create('test-storage');
expect(mockFs.readFile).toHaveBeenCalledWith(ENCRYPTION_MASTER_KEY_PATH);
expect(mockFs.writeFile).toHaveBeenCalledWith(
ENCRYPTION_MASTER_KEY_PATH,
expect.any(Buffer),
{ mode: 0o600 },
);
});
});
describe('when master key exists', () => {
it('should load the master key without creating a new one', async () => {
const masterKey = crypto.randomBytes(32);
mockFs.readFile.mockResolvedValue(masterKey);
storage = await FileTokenStorage.create('test-storage');
expect(mockFs.readFile).toHaveBeenCalledWith(ENCRYPTION_MASTER_KEY_PATH);
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
});
describe('getCredentials', () => {
beforeEach(async () => {
// All tests assume a master key exists.
const masterKey = crypto.randomBytes(32);
mockFs.readFile.mockResolvedValue(masterKey);
storage = await FileTokenStorage.create('test-storage');
});
it('should return null when file does not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
it('should return credentials even if access token is expired', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() - 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = (storage as any).encrypt(
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toEqual(credentials);
});
it('should return credentials for valid tokens', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = (storage as any).encrypt(
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toEqual(credentials);
});
it('should return null for corrupted files', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
});
describe('setCredentials', () => {
beforeEach(async () => {
// All tests assume a master key exists.
const masterKey = crypto.randomBytes(32);
mockFs.readFile.mockResolvedValue(masterKey);
storage = await FileTokenStorage.create('test-storage');
});
it('should save credentials with encryption', async () => {
const encryptedData = (storage as any).encrypt(
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(credentials);
expect(mockFs.mkdir).toHaveBeenCalledWith(
path.dirname(ENCRYPTED_TOKEN_PATH),
{ recursive: true, mode: 0o700 },
);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
expect(writeCall[0]).toBe(ENCRYPTED_TOKEN_PATH);
expect(writeCall[1]).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
expect(writeCall[2]).toEqual({ mode: 0o600 });
});
it('should update existing credentials', async () => {
const encryptedData = (storage as any).encrypt(
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.writeFile.mockResolvedValue(undefined);
const newCredentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'new-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(newCredentials);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
const decrypted = (storage as any).decrypt(writeCall[1]);
const saved = JSON.parse(decrypted);
expect(saved['existing-server']).toEqual(existingCredentials);
expect(saved['test-server'].token.accessToken).toBe('new-token');
});
});
describe('deleteCredentials', () => {
beforeEach(async () => {
// All tests assume a master key exists.
const masterKey = crypto.randomBytes(32);
mockFs.readFile.mockResolvedValue(masterKey);
storage = await FileTokenStorage.create('test-storage');
});
it('should throw when credentials do not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
await expect(storage.deleteCredentials('test-server')).rejects.toThrow(
'No credentials found for test-server',
);
});
it('should delete file when last credential is removed', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const encryptedData = (storage as any).encrypt(
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.unlink.mockResolvedValue(undefined);
await storage.deleteCredentials('test-server');
expect(mockFs.unlink).toHaveBeenCalledWith(ENCRYPTED_TOKEN_PATH);
});
it('should update file when other credentials remain', async () => {
const credentials1: OAuthCredentia
gitextract_ojj5tm71/
├── .gemini/
│ └── skills/
│ └── code-reviewer/
│ └── SKILL.md
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── ci.yml
│ ├── deploy-docs.yml
│ ├── release.yml
│ └── weekly-preview.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── GEMINI.md
├── LICENSE
├── README.md
├── SECURITY.md
├── cloud_function/
│ ├── index.js
│ └── package.json
├── commands/
│ ├── calendar/
│ │ ├── clear-schedule.toml
│ │ └── get-schedule.toml
│ ├── drive/
│ │ └── search.toml
│ └── gmail/
│ └── search.toml
├── docs/
│ ├── .vitepress/
│ │ └── config.mts
│ ├── GCP-RECREATION.md
│ ├── development.md
│ ├── feature-configuration.md
│ ├── index.md
│ ├── release.md
│ └── release_notes.md
├── eslint.config.js
├── gemini-extension.json
├── jest.config.js
├── package.json
├── scripts/
│ ├── auth-utils.js
│ ├── clean.js
│ ├── list-deps.js
│ ├── print-scopes.ts
│ ├── release.js
│ ├── set-version.js
│ ├── setup-gcp.sh
│ ├── start.js
│ ├── tsconfig.json
│ └── utils/
│ └── dependencies.js
├── skills/
│ ├── gmail/
│ │ └── SKILL.md
│ ├── google-calendar/
│ │ └── SKILL.md
│ ├── google-chat/
│ │ └── SKILL.md
│ ├── google-docs/
│ │ └── SKILL.md
│ ├── google-sheets/
│ │ └── SKILL.md
│ └── google-slides/
│ └── SKILL.md
├── tsconfig.json
└── workspace-server/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── WORKSPACE-Context.md
├── esbuild.auth-utils.js
├── esbuild.config.js
├── esbuild.headless-login.js
├── jest.config.js
├── package.json
├── src/
│ ├── __tests__/
│ │ ├── auth/
│ │ │ ├── AuthManager.test.ts
│ │ │ └── token-storage/
│ │ │ ├── base-token-storage.test.ts
│ │ │ ├── file-token-storage.test.ts
│ │ │ ├── hybrid-token-storage.test.ts
│ │ │ ├── keychain-token-storage.test.ts
│ │ │ └── oauth-credential-storage.test.ts
│ │ ├── features/
│ │ │ ├── feature-config.test.ts
│ │ │ └── feature-resolver.test.ts
│ │ ├── mocks/
│ │ │ └── wasm.js
│ │ ├── services/
│ │ │ ├── CalendarService.test.ts
│ │ │ ├── CalendarValidation.test.ts
│ │ │ ├── ChatService.test.ts
│ │ │ ├── DocsService.comments.test.ts
│ │ │ ├── DocsService.test.ts
│ │ │ ├── DriveService.test.ts
│ │ │ ├── GmailService.test.ts
│ │ │ ├── PeopleService.test.ts
│ │ │ ├── SheetsService.test.ts
│ │ │ ├── SlidesService.test.ts
│ │ │ └── TimeService.test.ts
│ │ ├── setup.ts
│ │ ├── tool-normalization.test.ts
│ │ └── utils/
│ │ ├── DriveQueryBuilder.test.ts
│ │ ├── IdUtils.test.ts
│ │ ├── MimeHelper.test.ts
│ │ ├── config.test.ts
│ │ ├── logger.test.ts
│ │ ├── paths.test.ts
│ │ ├── secure-browser-launcher.test.ts
│ │ └── validation.test.ts
│ ├── auth/
│ │ ├── AuthManager.ts
│ │ ├── scopes.ts
│ │ └── token-storage/
│ │ ├── base-token-storage.ts
│ │ ├── file-token-storage.ts
│ │ ├── hybrid-token-storage.ts
│ │ ├── index.ts
│ │ ├── keychain-token-storage.ts
│ │ ├── oauth-credential-storage.ts
│ │ └── types.ts
│ ├── cli/
│ │ └── headless-login.ts
│ ├── features/
│ │ ├── feature-config.ts
│ │ ├── feature-resolver.ts
│ │ └── index.ts
│ ├── index.ts
│ ├── services/
│ │ ├── CalendarService.ts
│ │ ├── CalendarValidation.ts
│ │ ├── ChatService.ts
│ │ ├── DocsService.ts
│ │ ├── DriveService.ts
│ │ ├── GmailService.ts
│ │ ├── PeopleService.ts
│ │ ├── SheetsService.ts
│ │ ├── SlidesService.ts
│ │ └── TimeService.ts
│ └── utils/
│ ├── DriveQueryBuilder.ts
│ ├── GaxiosConfig.ts
│ ├── IdUtils.ts
│ ├── MimeHelper.ts
│ ├── config.ts
│ ├── constants.ts
│ ├── logger.ts
│ ├── open-wrapper.ts
│ ├── paths.ts
│ ├── secure-browser-launcher.ts
│ ├── tool-normalization.ts
│ └── validation.ts
├── tsconfig.json
└── tsconfig.test.json
SYMBOL INDEX (245 symbols across 45 files)
FILE: cloud_function/index.js
constant CLIENT_ID (line 17) | const CLIENT_ID = process.env.CLIENT_ID;
constant SECRET_NAME (line 18) | const SECRET_NAME = process.env.SECRET_NAME;
constant REDIRECT_URI (line 19) | const REDIRECT_URI = process.env.REDIRECT_URI;
constant KEYCHAIN_SERVICE_NAME (line 22) | const KEYCHAIN_SERVICE_NAME = 'gemini-cli-workspace-oauth';
constant KEYCHAIN_ACCOUNT_NAME (line 23) | const KEYCHAIN_ACCOUNT_NAME = 'main-account';
function getClientSecret (line 32) | async function getClientSecret() {
function handleCallback (line 51) | async function handleCallback(req, res) {
function handleRefreshToken (line 284) | async function handleRefreshToken(req, res) {
FILE: scripts/auth-utils.js
function clearAuth (line 11) | async function clearAuth() {
function expireToken (line 21) | async function expireToken() {
function showStatus (line 40) | async function showStatus() {
function login (line 77) | async function login() {
function showHelp (line 89) | function showHelp() {
function main (line 110) | async function main() {
FILE: scripts/clean.js
constant RMRF (line 11) | const RMRF = { recursive: true, force: true };
function rmrfSyncVerbose (line 13) | function rmrfSyncVerbose(path) {
FILE: scripts/start.js
function runCommand (line 10) | function runCommand(command, args, options) {
function main (line 42) | async function main() {
FILE: scripts/utils/dependencies.js
function getDependencies (line 16) | function getDependencies(rootDir, pkgName) {
function getTransitiveDependencies (line 31) | function getTransitiveDependencies(rootDir, startPkgs) {
FILE: workspace-server/esbuild.auth-utils.js
function buildAuthUtils (line 10) | async function buildAuthUtils() {
FILE: workspace-server/esbuild.config.js
function build (line 11) | async function build() {
FILE: workspace-server/esbuild.headless-login.js
function buildHeadlessLogin (line 9) | async function buildHeadlessLogin() {
FILE: workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts
class TestTokenStorage (line 14) | class TestTokenStorage extends BaseTokenStorage {
method getCredentials (line 17) | async getCredentials(serverName: string): Promise<OAuthCredentials | n...
method setCredentials (line 21) | async setCredentials(credentials: OAuthCredentials): Promise<void> {
method deleteCredentials (line 26) | async deleteCredentials(serverName: string): Promise<void> {
method listServers (line 30) | async listServers(): Promise<string[]> {
method getAllCredentials (line 34) | async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
method clearAll (line 38) | async clearAll(): Promise<void> {
method validateCredentials (line 42) | override validateCredentials(credentials: OAuthCredentials): void {
method sanitizeServerName (line 46) | override sanitizeServerName(serverName: string): string {
FILE: workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts
constant KEYCHAIN_TOKEN_STORAGE_PATH (line 21) | const KEYCHAIN_TOKEN_STORAGE_PATH =
constant FILE_TOKEN_STORAGE_PATH (line 23) | const FILE_TOKEN_STORAGE_PATH =
constant HYBRID_TOKEN_STORAGE_PATH (line 25) | const HYBRID_TOKEN_STORAGE_PATH =
type MockStorage (line 28) | interface MockStorage {
FILE: workspace-server/src/__tests__/services/CalendarValidation.test.ts
function getZodIssueMessages (line 14) | function getZodIssueMessages(fn: () => void): string[] {
FILE: workspace-server/src/__tests__/utils/logger.test.ts
function setupLogger (line 26) | async function setupLogger(appendFileMock?: any) {
FILE: workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts
function simulateSuccess (line 38) | function simulateSuccess() {
function simulateFailure (line 44) | function simulateFailure(error = new Error('Command failed')) {
FILE: workspace-server/src/auth/AuthManager.ts
constant CLIENT_ID (line 19) | const CLIENT_ID = config.clientId;
constant CLOUD_FUNCTION_URL (line 20) | const CLOUD_FUNCTION_URL = config.cloudFunctionUrl;
constant TOKEN_EXPIRY_BUFFER_MS (line 21) | const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
type OauthWebLogin (line 28) | interface OauthWebLogin {
class AuthManager (line 33) | class AuthManager {
method constructor (line 38) | constructor(scopes: string[]) {
method setOnStatusUpdate (line 42) | public setOnStatusUpdate(callback: (message: string) => void) {
method isTokenExpiringSoon (line 46) | private isTokenExpiringSoon(credentials: Auth.Credentials): boolean {
method loadCachedCredentials (line 53) | private async loadCachedCredentials(
method getAuthenticatedClient (line 84) | public async getAuthenticatedClient(): Promise<Auth.OAuth2Client> {
method clearAuth (line 214) | public async clearAuth(): Promise<void> {
method refreshToken (line 221) | public async refreshToken(): Promise<void> {
method getAvailablePort (line 273) | private async getAvailablePort(): Promise<number> {
method authWithWeb (line 304) | private async authWithWeb(client: Auth.OAuth2Client): Promise<OauthWeb...
FILE: workspace-server/src/auth/scopes.ts
constant SCOPES (line 17) | const SCOPES: string[] = resolveFeatures(
FILE: workspace-server/src/auth/token-storage/base-token-storage.ts
method constructor (line 12) | constructor(serviceName: string) {
method validateCredentials (line 23) | protected validateCredentials(credentials: OAuthCredentials): void {
method sanitizeServerName (line 38) | protected sanitizeServerName(serverName: string): string {
FILE: workspace-server/src/auth/token-storage/file-token-storage.ts
class FileTokenStorage (line 19) | class FileTokenStorage extends BaseTokenStorage {
method constructor (line 24) | private constructor(serviceName: string, masterKey: Buffer) {
method create (line 31) | static async create(serviceName: string): Promise<FileTokenStorage> {
method loadMasterKey (line 36) | private static async loadMasterKey(): Promise<Buffer> {
method deriveEncryptionKey (line 51) | private deriveEncryptionKey(): Buffer {
method encrypt (line 58) | private encrypt(text: string): string {
method decrypt (line 70) | private decrypt(encryptedData: string): string {
method ensureDirectoryExists (line 93) | private async ensureDirectoryExists(): Promise<void> {
method loadTokens (line 98) | private async loadTokens(): Promise<Map<string, OAuthCredentials>> {
method saveTokens (line 123) | private async saveTokens(
method getCredentials (line 135) | async getCredentials(serverName: string): Promise<OAuthCredentials | n...
method setCredentials (line 146) | async setCredentials(credentials: OAuthCredentials): Promise<void> {
method deleteCredentials (line 159) | async deleteCredentials(serverName: string): Promise<void> {
method listServers (line 182) | async listServers(): Promise<string[]> {
method getAllCredentials (line 187) | async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
method clearAll (line 203) | async clearAll(): Promise<void> {
FILE: workspace-server/src/auth/token-storage/hybrid-token-storage.ts
constant FORCE_FILE_STORAGE_ENV_VAR (line 12) | const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_CLI_WORKSPACE_FORCE_FILE_STOR...
class HybridTokenStorage (line 14) | class HybridTokenStorage extends BaseTokenStorage {
method constructor (line 19) | constructor(serviceName: string) {
method initializeStorage (line 23) | private async initializeStorage(): Promise<TokenStorage> {
method getStorage (line 52) | private async getStorage(): Promise<TokenStorage> {
method getCredentials (line 66) | async getCredentials(serverName: string): Promise<OAuthCredentials | n...
method setCredentials (line 71) | async setCredentials(credentials: OAuthCredentials): Promise<void> {
method deleteCredentials (line 76) | async deleteCredentials(serverName: string): Promise<void> {
method listServers (line 81) | async listServers(): Promise<string[]> {
method getAllCredentials (line 86) | async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
method clearAll (line 91) | async clearAll(): Promise<void> {
method getStorageType (line 96) | async getStorageType(): Promise<TokenStorageType> {
FILE: workspace-server/src/auth/token-storage/keychain-token-storage.ts
type Keytar (line 11) | interface Keytar {
constant KEYCHAIN_TEST_PREFIX (line 24) | const KEYCHAIN_TEST_PREFIX = '__keychain_test__';
class KeychainTokenStorage (line 26) | class KeychainTokenStorage extends BaseTokenStorage {
method getKeytar (line 31) | async getKeytar(): Promise<Keytar | null> {
method getCredentials (line 50) | async getCredentials(serverName: string): Promise<OAuthCredentials | n...
method setCredentials (line 79) | async setCredentials(credentials: OAuthCredentials): Promise<void> {
method deleteCredentials (line 101) | async deleteCredentials(serverName: string): Promise<void> {
method listServers (line 122) | async listServers(): Promise<string[]> {
method getAllCredentials (line 143) | async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
method clearAll (line 178) | async clearAll(): Promise<void> {
method checkKeychainAvailability (line 212) | async checkKeychainAvailability(): Promise<boolean> {
method isAvailable (line 243) | async isAvailable(): Promise<boolean> {
FILE: workspace-server/src/auth/token-storage/oauth-credential-storage.ts
constant KEYCHAIN_SERVICE_NAME (line 11) | const KEYCHAIN_SERVICE_NAME = 'gemini-cli-workspace-oauth';
constant MAIN_ACCOUNT_KEY (line 12) | const MAIN_ACCOUNT_KEY = 'main-account';
class OAuthCredentialStorage (line 14) | class OAuthCredentialStorage {
method loadCredentials (line 22) | static async loadCredentials(): Promise<Credentials | null> {
method saveCredentials (line 53) | static async saveCredentials(credentials: Credentials): Promise<void> {
method clearCredentials (line 73) | static async clearCredentials(): Promise<void> {
FILE: workspace-server/src/auth/token-storage/types.ts
type OAuthToken (line 10) | interface OAuthToken {
type OAuthCredentials (line 21) | interface OAuthCredentials {
type TokenStorageType (line 30) | enum TokenStorageType {
type TokenStorage (line 35) | interface TokenStorage {
FILE: workspace-server/src/cli/headless-login.ts
constant CLIENT_ID (line 32) | const CLIENT_ID = config.clientId;
constant CLOUD_FUNCTION_URL (line 33) | const CLOUD_FUNCTION_URL = config.cloudFunctionUrl;
type CredentialsJson (line 35) | interface CredentialsJson {
constant TTY_PATH (line 43) | const TTY_PATH = os.platform() === 'win32' ? '\\\\.\\CON' : '/dev/tty';
function openTtyRead (line 50) | function openTtyRead(): fs.ReadStream {
function openTtyWrite (line 57) | function openTtyWrite(): fs.WriteStream {
function readCredentialsFromTty (line 65) | function readCredentialsFromTty(): Promise<string> {
function validateCredentials (line 129) | function validateCredentials(
function generateOAuthUrl (line 147) | function generateOAuthUrl(): string {
function main (line 167) | async function main() {
FILE: workspace-server/src/features/feature-config.ts
constant SCOPE_PREFIX (line 20) | const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
function scopes (line 22) | function scopes(...names: string[]): string[] {
type ServiceName (line 26) | type ServiceName =
type FeatureGroup (line 38) | interface FeatureGroup {
function featureGroupKey (line 54) | function featureGroupKey(fg: FeatureGroup): string {
constant FEATURE_GROUPS (line 58) | const FEATURE_GROUPS: readonly FeatureGroup[] = [
function getAllPossibleScopes (line 270) | function getAllPossibleScopes(): string[] {
FILE: workspace-server/src/features/feature-resolver.ts
type ResolvedFeatures (line 32) | interface ResolvedFeatures {
type Override (line 39) | interface Override {
function parseOverrides (line 50) | function parseOverrides(raw: string): Override[] {
constant GROUP_INDEX (line 83) | const GROUP_INDEX: ReadonlyMap<string, FeatureGroup> = new Map(
constant TOOL_INDEX (line 88) | const TOOL_INDEX: ReadonlyMap<string, string> = new Map(
function resolveFeatures (line 101) | function resolveFeatures(
FILE: workspace-server/src/index.ts
function main (line 104) | async function main() {
FILE: workspace-server/src/services/CalendarService.ts
type EventAttachment (line 22) | interface EventAttachment {
type CalendarEventType (line 28) | type CalendarEventType =
type ListEventsEventType (line 34) | type ListEventsEventType = CalendarEventType | 'birthday' | 'fromGmail';
type CreateEventInput (line 36) | interface CreateEventInput {
type ListEventsInput (line 69) | interface ListEventsInput {
type GetEventInput (line 77) | interface GetEventInput {
type DeleteEventInput (line 82) | interface DeleteEventInput {
type UpdateEventInput (line 87) | interface UpdateEventInput {
type RespondToEventInput (line 99) | interface RespondToEventInput {
type FindFreeTimeInput (line 107) | interface FindFreeTimeInput {
class CalendarService (line 114) | class CalendarService {
method constructor (line 117) | constructor(private authManager: any) {}
method applyMeetAndAttachments (line 125) | private applyMeetAndAttachments(
method createValidationErrorResponse (line 154) | private createValidationErrorResponse(error: unknown) {
method extractErrorMessage (line 194) | private extractErrorMessage(error: unknown): string {
method getCalendar (line 249) | private async getCalendar(): Promise<calendar_v3.Calendar> {
method getPrimaryCalendarId (line 257) | private async getPrimaryCalendarId(): Promise<string> {
FILE: workspace-server/src/services/CalendarValidation.ts
type EventDateInput (line 15) | type EventDateInput = {
type WorkingLocationValidationInput (line 20) | type WorkingLocationValidationInput = {
type CompleteEventValidationInput (line 26) | type CompleteEventValidationInput = {
function createIssue (line 47) | function createIssue(path: (string | number)[], message: string): z.ZodE...
function validateExclusiveDateField (line 57) | function validateExclusiveDateField(
function validateOptionalExclusiveDateField (line 72) | function validateOptionalExclusiveDateField(
function validateDateFieldFormats (line 91) | function validateDateFieldFormats(
function validateWorkingLocationProperties (line 103) | function validateWorkingLocationProperties(
function addDays (line 134) | function addDays(date: string, days: number): string {
function validateWorkingLocationDuration (line 140) | function validateWorkingLocationDuration(
function validateCompleteEventInput (line 163) | function validateCompleteEventInput(input: CompleteEventValidationInput)...
function validateCreateEventInput (line 193) | function validateCreateEventInput(input: CreateEventInput): void {
function validateUpdateEventInput (line 204) | function validateUpdateEventInput(input: UpdateEventInput): void {
FILE: workspace-server/src/services/ChatService.ts
type GetMessagesParams (line 12) | interface GetMessagesParams {
class ChatService (line 21) | class ChatService {
method constructor (line 22) | constructor(private authManager: AuthManager) {}
method getChatClient (line 24) | private async getChatClient(): Promise<chat_v1.Chat> {
method getPeopleClient (line 30) | private async getPeopleClient(): Promise<people_v1.People> {
method _setupDmSpace (line 36) | private async _setupDmSpace(email: string): Promise<chat_v1.Schema$Spa...
FILE: workspace-server/src/services/DocsService.ts
constant TABS_FIELD_MASK (line 17) | const TABS_FIELD_MASK =
type BaseDocsSuggestion (line 20) | interface BaseDocsSuggestion {
type DocsInsertionSuggestion (line 26) | interface DocsInsertionSuggestion extends BaseDocsSuggestion {
type DocsDeletionSuggestion (line 31) | interface DocsDeletionSuggestion extends BaseDocsSuggestion {
type DocsStyleChangeSuggestion (line 36) | interface DocsStyleChangeSuggestion extends BaseDocsSuggestion {
type DocsParagraphStyleChangeSuggestion (line 42) | interface DocsParagraphStyleChangeSuggestion extends BaseDocsSuggestion {
type DocsSuggestion (line 48) | type DocsSuggestion =
class DocsService (line 54) | class DocsService {
method _flattenTabs (line 59) | private _flattenTabs(tabs: docs_v1.Schema$Tab[]): docs_v1.Schema$Tab[] {
method constructor (line 66) | constructor(private authManager: AuthManager) {}
method getDocsClient (line 68) | private async getDocsClient(): Promise<docs_v1.Docs> {
method _extractSuggestions (line 125) | private _extractSuggestions(
method _getParagraphText (line 204) | private _getParagraphText(
method _readStructuralElement (line 670) | private _readStructuralElement(
method _renderPersonChip (line 702) | private _renderPersonChip(props: docs_v1.Schema$PersonProperties): str...
method _renderRichLinkChip (line 710) | private _renderRichLinkChip(
method _renderDateChip (line 720) | private _renderDateChip(props: docs_v1.Schema$DateElementProperties): ...
method _generateReplacementRequests (line 816) | private _generateReplacementRequests(
method _getFullDocumentText (line 865) | private _getFullDocumentText(
FILE: workspace-server/src/services/DriveService.ts
constant MIN_DRIVE_ID_LENGTH (line 17) | const MIN_DRIVE_ID_LENGTH = 25;
constant URL_PATTERNS (line 19) | const URL_PATTERNS = [
class DriveService (line 29) | class DriveService {
method constructor (line 30) | constructor(private authManager: AuthManager) {}
method getDriveClient (line 32) | private async getDriveClient(): Promise<drive_v3.Drive> {
method handleError (line 38) | private handleError(
FILE: workspace-server/src/services/GmailService.ts
type SendEmailParams (line 22) | type SendEmailParams = {
type CreateDraftParams (line 31) | type CreateDraftParams = SendEmailParams & {
type GmailAttachment (line 35) | interface GmailAttachment {
class GmailService (line 42) | class GmailService {
method constructor (line 43) | constructor(private authManager: AuthManager) {}
method getGmailClient (line 45) | private async getGmailClient(): Promise<gmail_v1.Gmail> {
method handleError (line 54) | private handleError(error: unknown, context: string) {
method extractAttachmentsAndBody (line 710) | private extractAttachmentsAndBody(
FILE: workspace-server/src/services/PeopleService.ts
class PeopleService (line 12) | class PeopleService {
method constructor (line 13) | constructor(private authManager: AuthManager) {}
method getPeopleClient (line 15) | private async getPeopleClient(): Promise<people_v1.People> {
FILE: workspace-server/src/services/SheetsService.ts
class SheetsService (line 13) | class SheetsService {
method constructor (line 14) | constructor(private authManager: AuthManager) {}
method getSheetsClient (line 16) | private async getSheetsClient(): Promise<sheets_v4.Sheets> {
FILE: workspace-server/src/services/SlidesService.ts
class SlidesService (line 16) | class SlidesService {
method constructor (line 17) | constructor(private authManager: AuthManager) {}
method getSlidesClient (line 19) | private async getSlidesClient(): Promise<slides_v1.Slides> {
method extractTextFromTextContent (line 111) | private extractTextFromTextContent(
method downloadToLocal (line 184) | private async downloadToLocal(url: string, localPath: string) {
FILE: workspace-server/src/services/TimeService.ts
class TimeService (line 9) | class TimeService {
method constructor (line 10) | constructor() {
method handleErrors (line 14) | private async handleErrors<T>(
method getTimeContext (line 42) | private getTimeContext() {
FILE: workspace-server/src/utils/DriveQueryBuilder.ts
function escapeQueryString (line 16) | function escapeQueryString(str: string): string {
FILE: workspace-server/src/utils/IdUtils.ts
constant DOC_ID_REGEX (line 9) | const DOC_ID_REGEX = /\/d\/([a-zA-Z0-9-_]+)/;
function extractDocId (line 17) | function extractDocId(url: string): string | undefined {
FILE: workspace-server/src/utils/MimeHelper.ts
class MimeHelper (line 10) | class MimeHelper {
method createMimeMessage (line 14) | public static createMimeMessage({
method createMimeMessageWithAttachments (line 98) | public static createMimeMessageWithAttachments({
method decodeBase64Url (line 205) | public static decodeBase64Url(encoded: string): string {
FILE: workspace-server/src/utils/config.ts
type WorkspaceConfig (line 9) | interface WorkspaceConfig {
constant DEFAULT_CONFIG (line 14) | const DEFAULT_CONFIG: WorkspaceConfig = {
function loadConfig (line 24) | function loadConfig(): WorkspaceConfig {
FILE: workspace-server/src/utils/constants.ts
constant GMAIL_SEARCH_MAX_RESULTS (line 7) | const GMAIL_SEARCH_MAX_RESULTS = 100;
constant GMAIL_BATCH_MODIFY_MAX_IDS (line 8) | const GMAIL_BATCH_MODIFY_MAX_IDS = 1000;
constant GMAIL_NO_LABEL_CHANGES_MESSAGE (line 9) | const GMAIL_NO_LABEL_CHANGES_MESSAGE =
FILE: workspace-server/src/utils/logger.ts
function ensureLogDirectoryExists (line 13) | async function ensureLogDirectoryExists() {
function setLoggingEnabled (line 27) | function setLoggingEnabled(enabled: boolean) {
function logToFile (line 31) | function logToFile(message: string) {
FILE: workspace-server/src/utils/paths.ts
function findProjectRoot (line 10) | function findProjectRoot(): string {
constant PROJECT_ROOT (line 24) | const PROJECT_ROOT = findProjectRoot();
constant ENCRYPTED_TOKEN_PATH (line 25) | const ENCRYPTED_TOKEN_PATH = path.join(
constant ENCRYPTION_MASTER_KEY_PATH (line 29) | const ENCRYPTION_MASTER_KEY_PATH = path.join(
FILE: workspace-server/src/utils/secure-browser-launcher.ts
function withTimeout (line 11) | function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
function validateUrl (line 28) | function validateUrl(url: string): void {
function openBrowserSecurely (line 61) | async function openBrowserSecurely(
function shouldLaunchBrowser (line 195) | function shouldLaunchBrowser(): boolean {
FILE: workspace-server/src/utils/tool-normalization.ts
function applyToolNameNormalization (line 18) | function applyToolNameNormalization(
FILE: workspace-server/src/utils/validation.ts
function createValidator (line 110) | function createValidator<T>(
function extractDocumentId (line 154) | function extractDocumentId(urlOrId: string): string {
class ValidationError (line 172) | class ValidationError extends Error {
method constructor (line 173) | constructor(
Condensed preview — 124 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (833K chars).
[
{
"path": ".gemini/skills/code-reviewer/SKILL.md",
"chars": 4187,
"preview": "---\nname: code-reviewer\ndescription:\n Use this skill to review code. It supports both local changes (staged or\n workin"
},
{
"path": ".github/dependabot.yml",
"chars": 1024,
"preview": "version: 2\nupdates:\n - package-ecosystem: 'npm'\n directory: '/'\n schedule:\n interval: 'weekly'\n open-pull"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2454,
"preview": "name: CI\n\non:\n push:\n branches: [main, develop]\n pull_request:\n branches: [main]\n\njobs:\n verify:\n runs-on: u"
},
{
"path": ".github/workflows/deploy-docs.yml",
"chars": 1218,
"preview": "name: Deploy Docs to GitHub Pages\n\non:\n push:\n branches:\n - main\n\njobs:\n build:\n runs-on: ubuntu-latest\n "
},
{
"path": ".github/workflows/release.yml",
"chars": 1068,
"preview": "name: Release\n\non:\n push:\n tags:\n - 'v*'\n\njobs:\n release:\n strategy:\n matrix:\n include:\n "
},
{
"path": ".github/workflows/weekly-preview.yml",
"chars": 1921,
"preview": "name: Weekly Preview Release\n\non:\n schedule:\n # Every Monday at 09:00 UTC\n - cron: '0 9 * * 1'\n workflow_dispatc"
},
{
"path": ".gitignore",
"chars": 400,
"preview": "# macOS\n.DS_Store\n\n# logs\nlogs\n\n# Dependencies\nnode_modules/\n\n# Build outputs\ndist/\n\n# Environment files\n.env\n.env.local"
},
{
"path": ".prettierignore",
"chars": 78,
"preview": "node_modules\ndist\ncoverage\n.vitepress/cache\n.vitepress/dist\npackage-lock.json\n"
},
{
"path": ".prettierrc.json",
"chars": 224,
"preview": "{\n \"semi\": true,\n \"trailingComma\": \"all\",\n \"singleQuote\": true,\n \"printWidth\": 80,\n \"tabWidth\": 2,\n \"overrides\": ["
},
{
"path": "CONTRIBUTING.md",
"chars": 4021,
"preview": "# How to Contribute\n\nWe would love to accept your patches and contributions to this project.\n\n## Before you begin\n\n### S"
},
{
"path": "GEMINI.md",
"chars": 1196,
"preview": "This is a Gemini extension that provides tools for interacting with Google\nWorkspace services like Google Docs.\n\n### Bui"
},
{
"path": "LICENSE",
"chars": 11357,
"preview": "\n Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 3880,
"preview": "# Google Workspace Extension for Gemini CLI\n\n[.\nWe use g.c"
},
{
"path": "cloud_function/index.js",
"chars": 13827,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Import required packages\ncon"
},
{
"path": "cloud_function/package.json",
"chars": 219,
"preview": "{\n \"name\": \"oauth-handler\",\n \"version\": \"1.0.0\",\n \"main\": \"index.js\",\n \"dependencies\": {\n \"@google-cloud/function"
},
{
"path": "commands/calendar/clear-schedule.toml",
"chars": 1689,
"preview": "description = \"Clear all events for a specific date or range by deleting or declining them\"\nprompt = \"\"\"\nPlease help me "
},
{
"path": "commands/calendar/get-schedule.toml",
"chars": 901,
"preview": "description = \"Show your schedule for today, or the date specified\"\nprompt = \"\"\"\nPlease show me my schedule for today. T"
},
{
"path": "commands/drive/search.toml",
"chars": 619,
"preview": "description = \"Searches Google Drive for files matching a query.\"\nprompt = \"\"\"\nYou are tasked with searching Google Driv"
},
{
"path": "commands/gmail/search.toml",
"chars": 826,
"preview": "description = \"Searches for emails in Gmail matching a query.\"\nprompt = \"\"\"\nYou are tasked with searching Gmail for emai"
},
{
"path": "docs/.vitepress/config.mts",
"chars": 1213,
"preview": "import { defineConfig } from 'vitepress';\n\n// https://vitepress.dev/reference/site-config\nexport default defineConfig({\n"
},
{
"path": "docs/GCP-RECREATION.md",
"chars": 2485,
"preview": "# Recreating the GCP Project\n\nThis guide provides step-by-step instructions to recreate the Google Cloud\nPlatform (GCP) "
},
{
"path": "docs/development.md",
"chars": 8492,
"preview": "# Development\n\nThis document provides instructions for developing the Google Workspace\nextension.\n\n## Development Setup "
},
{
"path": "docs/feature-configuration.md",
"chars": 7133,
"preview": "# Feature Configuration\n\nThe extension provides a feature configuration system that lets you control\nwhich services and "
},
{
"path": "docs/index.md",
"chars": 5118,
"preview": "# Google Workspace Extension Documentation\n\nThis document provides an overview of the Google Workspace extension for Gem"
},
{
"path": "docs/release.md",
"chars": 1812,
"preview": "# Release Process\n\nThis project uses GitHub Actions to automate the release process.\n\n## Prerequisites\n\n- [GitHub CLI](h"
},
{
"path": "docs/release_notes.md",
"chars": 6457,
"preview": "# Release Notes\n\n## 0.0.8 (2026-05-01)\n\n### New Features\n\n- **Google Calendar**: Added support for `eventType` (Out of O"
},
{
"path": "eslint.config.js",
"chars": 2903,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst tseslint = require('@type"
},
{
"path": "gemini-extension.json",
"chars": 271,
"preview": "{\n \"name\": \"google-workspace\",\n \"version\": \"0.0.8\",\n \"contextFileName\": \"workspace-server${/}WORKSPACE-Context.md\",\n "
},
{
"path": "jest.config.js",
"chars": 1480,
"preview": "/** @type {import('jest').Config} */\nmodule.exports = {\n preset: 'ts-jest',\n testEnvironment: 'node',\n projects: [\n "
},
{
"path": "package.json",
"chars": 2749,
"preview": "{\n \"name\": \"gemini-workspace-extension\",\n \"version\": \"0.0.8\",\n \"description\": \"Google Workspace Server Extension\",\n "
},
{
"path": "scripts/auth-utils.js",
"chars": 3632,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst {\n OAuthCredentialStorag"
},
{
"path": "scripts/clean.js",
"chars": 829,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst { rmSync, readFileSync } "
},
{
"path": "scripts/list-deps.js",
"chars": 687,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst path = require('node:path"
},
{
"path": "scripts/print-scopes.ts",
"chars": 489,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Prints the OAuth scopes "
},
{
"path": "scripts/release.js",
"chars": 4685,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\n"
},
{
"path": "scripts/set-version.js",
"chars": 1679,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\n"
},
{
"path": "scripts/setup-gcp.sh",
"chars": 8665,
"preview": "#!/bin/bash\n\n# GCP Setup Script for Google Workspace Extension\n# This script is idempotent — it can be safely re-run wit"
},
{
"path": "scripts/start.js",
"chars": 1484,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst { spawn } = require('node"
},
{
"path": "scripts/tsconfig.json",
"chars": 142,
"preview": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"..\"\n },\n \"include\": [\"**/*.ts\", \"../workspac"
},
{
"path": "scripts/utils/dependencies.js",
"chars": 1501,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\n"
},
{
"path": "skills/gmail/SKILL.md",
"chars": 8204,
"preview": "---\nname: gmail\ndescription: >\n CRITICAL: You MUST activate this skill BEFORE composing, sending, drafting, or\n search"
},
{
"path": "skills/google-calendar/SKILL.md",
"chars": 14257,
"preview": "---\nname: google-calendar\ndescription: >\n CRITICAL: You MUST activate this skill BEFORE creating, querying, or managing"
},
{
"path": "skills/google-chat/SKILL.md",
"chars": 7355,
"preview": "---\nname: google-chat\ndescription: >\n CRITICAL: You MUST activate this skill BEFORE sending, reading, or managing\n Goo"
},
{
"path": "skills/google-docs/SKILL.md",
"chars": 8584,
"preview": "---\nname: google-docs\ndescription: >\n CRITICAL: You MUST activate this skill BEFORE creating, editing, or managing\n Go"
},
{
"path": "skills/google-sheets/SKILL.md",
"chars": 1635,
"preview": "---\nname: google-sheets\ndescription: >\n Activate this skill when the user wants to find, read, or analyze Google\n Shee"
},
{
"path": "skills/google-slides/SKILL.md",
"chars": 1752,
"preview": "---\nname: google-slides\ndescription: >\n Activate this skill when the user wants to find, read, or extract content from\n"
},
{
"path": "tsconfig.json",
"chars": 511,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2020\",\n \"module\": \"commonjs\",\n \"esModuleInterop\": true,\n \"allowSynthe"
},
{
"path": "workspace-server/.github/workflows/ci.yml",
"chars": 2349,
"preview": "name: CI\n\non:\n push:\n branches: [main, develop]\n pull_request:\n branches: [main]\n\njobs:\n test:\n runs-on: ubu"
},
{
"path": "workspace-server/.github/workflows/release.yml",
"chars": 1342,
"preview": "name: Release\n\non:\n push:\n tags:\n - 'v*'\n\njobs:\n release:\n runs-on: ubuntu-latest\n\n steps:\n - uses:"
},
{
"path": "workspace-server/WORKSPACE-Context.md",
"chars": 5733,
"preview": "# Google Workspace Extension - Behavioral Guide\n\nThis guide provides behavioral instructions for effectively using the G"
},
{
"path": "workspace-server/esbuild.auth-utils.js",
"chars": 787,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuil"
},
{
"path": "workspace-server/esbuild.config.js",
"chars": 987,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuil"
},
{
"path": "workspace-server/esbuild.headless-login.js",
"chars": 747,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuil"
},
{
"path": "workspace-server/jest.config.js",
"chars": 351,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/** @type {import('jest').Confi"
},
{
"path": "workspace-server/package.json",
"chars": 873,
"preview": "{\n \"name\": \"workspace-server\",\n \"version\": \"0.0.8\",\n \"description\": \"\",\n \"main\": \"dist/index.js\",\n \"scripts\": {\n "
},
{
"path": "workspace-server/src/__tests__/auth/AuthManager.test.ts",
"chars": 8624,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { AuthManager } from '.."
},
{
"path": "workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts",
"chars": 4449,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, "
},
{
"path": "workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts",
"chars": 12551,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts",
"chars": 8539,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts",
"chars": 12275,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts",
"chars": 3595,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, "
},
{
"path": "workspace-server/src/__tests__/features/feature-config.test.ts",
"chars": 4819,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { execSync } from 'node:"
},
{
"path": "workspace-server/src/__tests__/features/feature-resolver.test.ts",
"chars": 8572,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, "
},
{
"path": "workspace-server/src/__tests__/mocks/wasm.js",
"chars": 110,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nmodule.exports = {};\n"
},
{
"path": "workspace-server/src/__tests__/services/CalendarService.test.ts",
"chars": 72035,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/CalendarValidation.test.ts",
"chars": 6202,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect }"
},
{
"path": "workspace-server/src/__tests__/services/ChatService.test.ts",
"chars": 20224,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/DocsService.comments.test.ts",
"chars": 11874,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/DocsService.test.ts",
"chars": 32539,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/DriveService.test.ts",
"chars": 42316,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/GmailService.test.ts",
"chars": 32909,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/PeopleService.test.ts",
"chars": 8819,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/SheetsService.test.ts",
"chars": 10417,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/SlidesService.test.ts",
"chars": 12153,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/services/TimeService.test.ts",
"chars": 1805,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { TimeService } from '.."
},
{
"path": "workspace-server/src/__tests__/setup.ts",
"chars": 748,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Test setup file for Jest\n// "
},
{
"path": "workspace-server/src/__tests__/tool-normalization.test.ts",
"chars": 1345,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { McpServer } from '@mod"
},
{
"path": "workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts",
"chars": 992,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect }"
},
{
"path": "workspace-server/src/__tests__/utils/IdUtils.test.ts",
"chars": 4461,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect }"
},
{
"path": "workspace-server/src/__tests__/utils/MimeHelper.test.ts",
"chars": 13486,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect }"
},
{
"path": "workspace-server/src/__tests__/utils/config.test.ts",
"chars": 1820,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/utils/logger.test.ts",
"chars": 7118,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/utils/paths.test.ts",
"chars": 1020,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport path from 'node:path';\ni"
},
{
"path": "workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts",
"chars": 9271,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n describe,\n it,\n ex"
},
{
"path": "workspace-server/src/__tests__/utils/validation.test.ts",
"chars": 6384,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect }"
},
{
"path": "workspace-server/src/auth/AuthManager.ts",
"chars": 14177,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, Auth } from 'g"
},
{
"path": "workspace-server/src/auth/scopes.ts",
"chars": 532,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { resolveFeatures } from"
},
{
"path": "workspace-server/src/auth/token-storage/base-token-storage.ts",
"chars": 1325,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { OAuthCredentials, Toke"
},
{
"path": "workspace-server/src/auth/token-storage/file-token-storage.ts",
"chars": 6139,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { promises as fs } from "
},
{
"path": "workspace-server/src/auth/token-storage/hybrid-token-storage.ts",
"chars": 3057,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { BaseTokenStorage } fro"
},
{
"path": "workspace-server/src/auth/token-storage/index.ts",
"chars": 230,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport * from './types';\nexport"
},
{
"path": "workspace-server/src/auth/token-storage/keychain-token-storage.ts",
"chars": 7041,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport * as crypto from 'node:c"
},
{
"path": "workspace-server/src/auth/token-storage/oauth-credential-storage.ts",
"chars": 2234,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { type Credentials } fro"
},
{
"path": "workspace-server/src/auth/token-storage/types.ts",
"chars": 942,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Interface for OAuth toke"
},
{
"path": "workspace-server/src/cli/headless-login.ts",
"chars": 6531,
"preview": "#!/usr/bin/env node\n\n/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Hea"
},
{
"path": "workspace-server/src/features/feature-config.ts",
"chars": 6273,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Feature Configuration Re"
},
{
"path": "workspace-server/src/features/feature-resolver.ts",
"chars": 5635,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Feature Resolver\n *\n * R"
},
{
"path": "workspace-server/src/features/index.ts",
"chars": 370,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport {\n FEATURE_GROUPS,\n fe"
},
{
"path": "workspace-server/src/index.ts",
"chars": 44085,
"preview": "#!/usr/bin/env node\n\n/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { M"
},
{
"path": "workspace-server/src/services/CalendarService.ts",
"chars": 29696,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport crypto from 'node:crypto"
},
{
"path": "workspace-server/src/services/CalendarValidation.ts",
"chars": 5751,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { z } from 'zod';\nimport"
},
{
"path": "workspace-server/src/services/ChatService.ts",
"chars": 15411,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, chat_v1, peopl"
},
{
"path": "workspace-server/src/services/DocsService.ts",
"chars": 24493,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, docs_v1 } from"
},
{
"path": "workspace-server/src/services/DriveService.ts",
"chars": 18257,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, drive_v3 } fro"
},
{
"path": "workspace-server/src/services/GmailService.ts",
"chars": 19675,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, gmail_v1 } fro"
},
{
"path": "workspace-server/src/services/PeopleService.ts",
"chars": 5738,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, people_v1 } fr"
},
{
"path": "workspace-server/src/services/SheetsService.ts",
"chars": 7546,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, sheets_v4 } fr"
},
{
"path": "workspace-server/src/services/SlidesService.ts",
"chars": 10682,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, slides_v1 } fr"
},
{
"path": "workspace-server/src/services/TimeService.ts",
"chars": 1972,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from '../u"
},
{
"path": "workspace-server/src/utils/DriveQueryBuilder.ts",
"chars": 413,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Utility for escaping Goo"
},
{
"path": "workspace-server/src/utils/GaxiosConfig.ts",
"chars": 884,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { GaxiosOptions } from '"
},
{
"path": "workspace-server/src/utils/IdUtils.ts",
"chars": 907,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from './lo"
},
{
"path": "workspace-server/src/utils/MimeHelper.ts",
"chars": 5194,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Helper class for creatin"
},
{
"path": "workspace-server/src/utils/config.ts",
"chars": 1103,
"preview": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from './lo"
},
{
"path": "workspace-server/src/utils/constants.ts",
"chars": 292,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport const GMAIL_SEARCH_MAX_R"
},
{
"path": "workspace-server/src/utils/logger.ts",
"chars": 1119,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport * as fs from 'node:fs/pr"
},
{
"path": "workspace-server/src/utils/open-wrapper.ts",
"chars": 1470,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * This module acts as a dr"
},
{
"path": "workspace-server/src/utils/paths.ts",
"chars": 811,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport path from 'node:path';\ni"
},
{
"path": "workspace-server/src/utils/secure-browser-launcher.ts",
"chars": 6935,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { execFile, ExecFileOpti"
},
{
"path": "workspace-server/src/utils/tool-normalization.ts",
"chars": 1306,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { McpServer } from '@mod"
},
{
"path": "workspace-server/src/utils/validation.ts",
"chars": 4599,
"preview": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { z } from 'zod';\n\n/**\n "
},
{
"path": "workspace-server/tsconfig.json",
"chars": 217,
"preview": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"./src\",\n \"outDir\": \"./dist\"\n },\n \"include"
},
{
"path": "workspace-server/tsconfig.test.json",
"chars": 166,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"strict\": false,\n \"noImplicitAny\": false\n },\n \"include"
}
]
About this extraction
This page contains the full source code of the gemini-cli-extensions/workspace GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 124 files (776.8 KB), approximately 185.3k tokens, and a symbol index with 245 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.